@kaleidorg/mind 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/qvac/assistant.d.ts +73 -0
- package/dist/qvac/assistant.d.ts.map +1 -0
- package/dist/qvac/assistant.js +97 -0
- package/dist/qvac/assistant.js.map +1 -0
- package/dist/qvac/config.d.ts +64 -0
- package/dist/qvac/config.d.ts.map +1 -0
- package/dist/qvac/config.js +71 -0
- package/dist/qvac/config.js.map +1 -0
- package/dist/qvac/delegate.d.ts +48 -0
- package/dist/qvac/delegate.d.ts.map +1 -0
- package/dist/qvac/delegate.js +51 -0
- package/dist/qvac/delegate.js.map +1 -0
- package/dist/qvac/index.d.ts +19 -0
- package/dist/qvac/index.d.ts.map +1 -0
- package/dist/qvac/index.js +19 -0
- package/dist/qvac/index.js.map +1 -0
- package/dist/qvac/parse.d.ts +44 -0
- package/dist/qvac/parse.d.ts.map +1 -0
- package/dist/qvac/parse.js +28 -0
- package/dist/qvac/parse.js.map +1 -0
- package/dist/qvac/provider.d.ts +49 -0
- package/dist/qvac/provider.d.ts.map +1 -0
- package/dist/qvac/provider.js +68 -0
- package/dist/qvac/provider.js.map +1 -0
- package/dist/qvac/stream.d.ts +37 -0
- package/dist/qvac/stream.d.ts.map +1 -0
- package/dist/qvac/stream.js +29 -0
- package/dist/qvac/stream.js.map +1 -0
- package/dist/qvac/text.d.ts +19 -0
- package/dist/qvac/text.d.ts.map +1 -0
- package/dist/qvac/text.js +56 -0
- package/dist/qvac/text.js.map +1 -0
- package/dist/qvac/voice.d.ts +69 -0
- package/dist/qvac/voice.d.ts.map +1 -0
- package/dist/qvac/voice.js +51 -0
- package/dist/qvac/voice.js.map +1 -0
- 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 +15 -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/assistant.test.ts +132 -0
- package/src/qvac/assistant.ts +146 -0
- package/src/qvac/config.test.ts +44 -0
- package/src/qvac/config.ts +76 -0
- package/src/qvac/delegate.test.ts +68 -0
- package/src/qvac/delegate.ts +71 -0
- package/src/qvac/index.ts +72 -0
- package/src/qvac/parse.test.ts +52 -0
- package/src/qvac/parse.ts +57 -0
- package/src/qvac/provider.test.ts +107 -0
- package/src/qvac/provider.ts +124 -0
- package/src/qvac/stream.test.ts +79 -0
- package/src/qvac/stream.ts +56 -0
- package/src/qvac/text.test.ts +70 -0
- package/src/qvac/text.ts +60 -0
- package/src/qvac/voice.test.ts +151 -0
- package/src/qvac/voice.ts +122 -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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createQvacVoice } from './voice.js';
|
|
3
|
+
|
|
4
|
+
describe('createQvacVoice.transcribeAudio', () => {
|
|
5
|
+
it('strips the file:// prefix and passes a plain path to the SDK', async () => {
|
|
6
|
+
const transcribe = vi.fn(async () => 'hello world');
|
|
7
|
+
const voice = createQvacVoice({
|
|
8
|
+
transcribe: transcribe as any,
|
|
9
|
+
textToSpeech: (() => {}) as any,
|
|
10
|
+
getWhisperModelId: () => 'whisper-1',
|
|
11
|
+
getTtsModelId: () => null,
|
|
12
|
+
});
|
|
13
|
+
const text = await voice.transcribeAudio('file:///tmp/clip.wav');
|
|
14
|
+
expect(text).toBe('hello world');
|
|
15
|
+
expect(transcribe).toHaveBeenCalledWith({ modelId: 'whisper-1', audioChunk: '/tmp/clip.wav' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('throws when no Whisper model is loaded', async () => {
|
|
19
|
+
const voice = createQvacVoice({
|
|
20
|
+
transcribe: (() => { throw new Error('nope'); }) as any,
|
|
21
|
+
textToSpeech: (() => {}) as any,
|
|
22
|
+
getWhisperModelId: () => null,
|
|
23
|
+
getTtsModelId: () => null,
|
|
24
|
+
});
|
|
25
|
+
await expect(voice.transcribeAudio('/tmp/clip.wav')).rejects.toThrow(/not loaded/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('createQvacVoice.synthesizeSpeech', () => {
|
|
30
|
+
function ttsReturning(pcm: number[]) {
|
|
31
|
+
const calls: any[] = [];
|
|
32
|
+
const fn = (params: any) => {
|
|
33
|
+
calls.push(params);
|
|
34
|
+
return { buffer: Promise.resolve(pcm) };
|
|
35
|
+
};
|
|
36
|
+
return { fn, calls };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it('returns null when no TTS model is loaded', async () => {
|
|
40
|
+
const voice = createQvacVoice({
|
|
41
|
+
transcribe: (() => {}) as any,
|
|
42
|
+
textToSpeech: (() => { throw new Error('should not run'); }) as any,
|
|
43
|
+
getWhisperModelId: () => null,
|
|
44
|
+
getTtsModelId: () => null,
|
|
45
|
+
});
|
|
46
|
+
expect(await voice.synthesizeSpeech('hi')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns null when the text is empty after sanitization', async () => {
|
|
50
|
+
const tts = ttsReturning([1, 2, 3]);
|
|
51
|
+
const voice = createQvacVoice({
|
|
52
|
+
transcribe: (() => {}) as any,
|
|
53
|
+
textToSpeech: tts.fn as any,
|
|
54
|
+
getWhisperModelId: () => null,
|
|
55
|
+
getTtsModelId: () => 'tts-1',
|
|
56
|
+
});
|
|
57
|
+
// Only non-ASCII / markup ⇒ sanitizes to empty ⇒ no synthesis attempted.
|
|
58
|
+
expect(await voice.synthesizeSpeech('✨🎉')).toBeNull();
|
|
59
|
+
expect(tts.calls).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('redacts payment strings before synthesis and returns PCM + sample rate', async () => {
|
|
63
|
+
const tts = ttsReturning([10, 20, 30]);
|
|
64
|
+
const voice = createQvacVoice({
|
|
65
|
+
transcribe: (() => {}) as any,
|
|
66
|
+
textToSpeech: tts.fn as any,
|
|
67
|
+
getWhisperModelId: () => null,
|
|
68
|
+
getTtsModelId: () => 'tts-1',
|
|
69
|
+
ttsSampleRate: 22050,
|
|
70
|
+
});
|
|
71
|
+
const out = await voice.synthesizeSpeech('Pay lnbc1' + 'q'.repeat(60) + ' now');
|
|
72
|
+
expect(out).toEqual({ pcm: [10, 20, 30], sampleRate: 22050 });
|
|
73
|
+
expect(tts.calls[0].text).toContain('Lightning invoice');
|
|
74
|
+
expect(tts.calls[0].text).not.toMatch(/lnbc1q/i);
|
|
75
|
+
expect(tts.calls[0]).toMatchObject({ modelId: 'tts-1', inputType: 'text', stream: false });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('defaults to the SUPERTONIC 44.1 kHz sample rate', async () => {
|
|
79
|
+
const tts = ttsReturning([1]);
|
|
80
|
+
const voice = createQvacVoice({
|
|
81
|
+
transcribe: (() => {}) as any,
|
|
82
|
+
textToSpeech: tts.fn as any,
|
|
83
|
+
getWhisperModelId: () => null,
|
|
84
|
+
getTtsModelId: () => 'tts-1',
|
|
85
|
+
});
|
|
86
|
+
const out = await voice.synthesizeSpeech('Your balance is five thousand sats');
|
|
87
|
+
expect(out?.sampleRate).toBe(44100);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('createQvacVoice.openVoiceSession', () => {
|
|
92
|
+
const fakeSession = {
|
|
93
|
+
write() {},
|
|
94
|
+
end() {},
|
|
95
|
+
destroy() {},
|
|
96
|
+
async *[Symbol.asyncIterator]() {},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
it('opens a VAD stream with the whisper model + default params', async () => {
|
|
100
|
+
const calls: any[] = [];
|
|
101
|
+
const transcribeStream = (p: any) => {
|
|
102
|
+
calls.push(p);
|
|
103
|
+
return Promise.resolve(fakeSession);
|
|
104
|
+
};
|
|
105
|
+
const voice = createQvacVoice({
|
|
106
|
+
transcribe: (() => {}) as any,
|
|
107
|
+
textToSpeech: (() => {}) as any,
|
|
108
|
+
transcribeStream: transcribeStream as any,
|
|
109
|
+
getWhisperModelId: () => 'w1',
|
|
110
|
+
getTtsModelId: () => null,
|
|
111
|
+
});
|
|
112
|
+
const session = await voice.openVoiceSession();
|
|
113
|
+
expect(session).toBe(fakeSession);
|
|
114
|
+
expect(calls[0]).toMatchObject({ modelId: 'w1', emitVadEvents: true, endOfTurnSilenceMs: 700 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('merges param overrides over the defaults', async () => {
|
|
118
|
+
const calls: any[] = [];
|
|
119
|
+
const voice = createQvacVoice({
|
|
120
|
+
transcribe: (() => {}) as any,
|
|
121
|
+
textToSpeech: (() => {}) as any,
|
|
122
|
+
transcribeStream: ((p: any) => { calls.push(p); return Promise.resolve(fakeSession); }) as any,
|
|
123
|
+
getWhisperModelId: () => 'w1',
|
|
124
|
+
getTtsModelId: () => null,
|
|
125
|
+
});
|
|
126
|
+
await voice.openVoiceSession({ endOfTurnSilenceMs: 1200 });
|
|
127
|
+
expect(calls[0].endOfTurnSilenceMs).toBe(1200);
|
|
128
|
+
expect(calls[0].emitVadEvents).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('throws when transcribeStream was not provided', async () => {
|
|
132
|
+
const voice = createQvacVoice({
|
|
133
|
+
transcribe: (() => {}) as any,
|
|
134
|
+
textToSpeech: (() => {}) as any,
|
|
135
|
+
getWhisperModelId: () => 'w1',
|
|
136
|
+
getTtsModelId: () => null,
|
|
137
|
+
});
|
|
138
|
+
await expect(voice.openVoiceSession()).rejects.toThrow(/transcribeStream not provided/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws when no Whisper model is loaded', async () => {
|
|
142
|
+
const voice = createQvacVoice({
|
|
143
|
+
transcribe: (() => {}) as any,
|
|
144
|
+
textToSpeech: (() => {}) as any,
|
|
145
|
+
transcribeStream: (() => Promise.resolve(fakeSession)) as any,
|
|
146
|
+
getWhisperModelId: () => null,
|
|
147
|
+
getTtsModelId: () => null,
|
|
148
|
+
});
|
|
149
|
+
await expect(voice.openVoiceSession()).rejects.toThrow(/not loaded/);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice runtime ops shared across hosts: one-shot transcription (Whisper) and
|
|
3
|
+
* speech synthesis (SUPERTONIC TTS). Like the provider, the SDK functions are
|
|
4
|
+
* injected (type-only `@qvac/sdk` import, erased at build) so this carries no
|
|
5
|
+
* runtime SDK dependency and is unit-testable with fakes.
|
|
6
|
+
*
|
|
7
|
+
* The host still owns model lifecycle (download, load, local-vs-delegated) and
|
|
8
|
+
* audio I/O (mic capture, playback). It passes the loaded model-id resolvers;
|
|
9
|
+
* this module does the SDK calls + the text gating that must be identical
|
|
10
|
+
* everywhere (payment-string redaction, U+0060 refusal, file:// stripping).
|
|
11
|
+
*
|
|
12
|
+
* The streaming voice-assistant loop (transcribeStream + VAD) builds on top of
|
|
13
|
+
* these in a later pass.
|
|
14
|
+
*/
|
|
15
|
+
import type * as QvacSdk from '@qvac/sdk';
|
|
16
|
+
import { sanitizeForSupertonic } from './text.js';
|
|
17
|
+
import { TTS_SAMPLE_RATE, DEFAULT_VOICE_STREAM_PARAMS } from './config.js';
|
|
18
|
+
import type { VoiceTranscriptEvent } from './assistant.js';
|
|
19
|
+
|
|
20
|
+
type TranscribeFn = typeof QvacSdk.transcribe;
|
|
21
|
+
type TextToSpeechFn = typeof QvacSdk.textToSpeech;
|
|
22
|
+
type TranscribeStreamFn = typeof QvacSdk.transcribeStream;
|
|
23
|
+
|
|
24
|
+
/** 16-bit PCM samples plus their sample rate, ready for the host to play. */
|
|
25
|
+
export interface PcmAudio {
|
|
26
|
+
pcm: number[];
|
|
27
|
+
sampleRate: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A live VAD transcription session: feed mic audio with `write()`, iterate to
|
|
32
|
+
* receive `text`/`vad`/`endOfTurn` events, `end()` when audio stops. Pass it
|
|
33
|
+
* straight to `runVoiceAssistant`.
|
|
34
|
+
*/
|
|
35
|
+
export interface VoiceSession {
|
|
36
|
+
write(audioChunk: Uint8Array): void;
|
|
37
|
+
end(): void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
[Symbol.asyncIterator](): AsyncIterator<VoiceTranscriptEvent>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface QvacVoiceOptions {
|
|
43
|
+
/** The SDK's `transcribe` (injected). */
|
|
44
|
+
transcribe: TranscribeFn;
|
|
45
|
+
/** The SDK's `textToSpeech` (injected). */
|
|
46
|
+
textToSpeech: TextToSpeechFn;
|
|
47
|
+
/** The SDK's `transcribeStream` (injected) — only needed for `openVoiceSession`. */
|
|
48
|
+
transcribeStream?: TranscribeStreamFn;
|
|
49
|
+
/** Resolve the loaded Whisper model id (null ⇒ not loaded → throws). */
|
|
50
|
+
getWhisperModelId: () => string | null;
|
|
51
|
+
/** Resolve the loaded TTS model id (null ⇒ not loaded → returns null). */
|
|
52
|
+
getTtsModelId: () => string | null;
|
|
53
|
+
/** TTS output sample rate; defaults to SUPERTONIC-2's 44.1 kHz. */
|
|
54
|
+
ttsSampleRate?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QvacVoice {
|
|
58
|
+
/** Transcribe an audio file (path or `file://` URI) to text. */
|
|
59
|
+
transcribeAudio(audioUri: string): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* Synthesize speech for `text`. Returns PCM + sample rate, or `null` when TTS
|
|
62
|
+
* is unavailable or the text is empty after sanitization (host falls back to
|
|
63
|
+
* the system voice). Payment strings are redacted so they're never read aloud.
|
|
64
|
+
*/
|
|
65
|
+
synthesizeSpeech(text: string): Promise<PcmAudio | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Open a hands-free VAD transcription session (continuous voice). Requires
|
|
68
|
+
* `transcribeStream` to have been provided. Merge in `paramsOverride` to tune
|
|
69
|
+
* the defaults ({@link DEFAULT_VOICE_STREAM_PARAMS}). Feed the returned session
|
|
70
|
+
* to `runVoiceAssistant`.
|
|
71
|
+
*/
|
|
72
|
+
openVoiceSession(paramsOverride?: Record<string, unknown>): Promise<VoiceSession>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createQvacVoice(options: QvacVoiceOptions): QvacVoice {
|
|
76
|
+
const sampleRate = options.ttsSampleRate ?? TTS_SAMPLE_RATE;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async transcribeAudio(audioUri: string): Promise<string> {
|
|
80
|
+
const modelId = options.getWhisperModelId();
|
|
81
|
+
if (!modelId) throw new Error('Whisper model not loaded');
|
|
82
|
+
// The SDK's native file reader wants a plain filesystem path, not a
|
|
83
|
+
// `file://` URI — the URI raises AUDIO_FILE_NOT_FOUND even when present.
|
|
84
|
+
const audioChunk = audioUri.replace('file://', '');
|
|
85
|
+
return await options.transcribe({ modelId, audioChunk } as Parameters<TranscribeFn>[0]);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async synthesizeSpeech(text: string): Promise<PcmAudio | null> {
|
|
89
|
+
const modelId = options.getTtsModelId();
|
|
90
|
+
if (!modelId) return null;
|
|
91
|
+
|
|
92
|
+
const trimmed = sanitizeForSupertonic(text);
|
|
93
|
+
if (!trimmed) return null;
|
|
94
|
+
// Belt-and-suspenders: SUPERTONIC chokes on U+0060; sanitize already
|
|
95
|
+
// strips it, so refuse if any slipped through rather than crash the voice.
|
|
96
|
+
if (Array.from(trimmed).some((ch) => ch.charCodeAt(0) === 0x60)) return null;
|
|
97
|
+
|
|
98
|
+
const result = options.textToSpeech({
|
|
99
|
+
modelId,
|
|
100
|
+
text: trimmed,
|
|
101
|
+
inputType: 'text',
|
|
102
|
+
stream: false,
|
|
103
|
+
} as Parameters<TextToSpeechFn>[0]);
|
|
104
|
+
const pcm = await result.buffer;
|
|
105
|
+
return { pcm, sampleRate };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async openVoiceSession(paramsOverride: Record<string, unknown> = {}): Promise<VoiceSession> {
|
|
109
|
+
if (!options.transcribeStream) {
|
|
110
|
+
throw new Error('transcribeStream not provided — pass it in QvacVoiceOptions for voice sessions');
|
|
111
|
+
}
|
|
112
|
+
const modelId = options.getWhisperModelId();
|
|
113
|
+
if (!modelId) throw new Error('Whisper model not loaded');
|
|
114
|
+
const session = await options.transcribeStream({
|
|
115
|
+
modelId,
|
|
116
|
+
...DEFAULT_VOICE_STREAM_PARAMS,
|
|
117
|
+
...paramsOverride,
|
|
118
|
+
} as Parameters<TranscribeStreamFn>[0]);
|
|
119
|
+
return session as unknown as VoiceSession;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -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
|
+
};
|