@kaleidorg/mind 0.2.0 → 0.4.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/capabilities.d.ts +4 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +7 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/funnel.d.ts +6 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +26 -6
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +72 -0
- package/dist/kaleidoswap/contract.d.ts.map +1 -0
- package/dist/kaleidoswap/contract.js +125 -0
- package/dist/kaleidoswap/contract.js.map +1 -0
- package/dist/knowledge/btc-map.d.ts +87 -0
- package/dist/knowledge/btc-map.d.ts.map +1 -0
- package/dist/knowledge/btc-map.js +365 -0
- package/dist/knowledge/btc-map.js.map +1 -0
- package/dist/lsps1/contract.d.ts +55 -0
- package/dist/lsps1/contract.d.ts.map +1 -0
- package/dist/lsps1/contract.js +91 -0
- package/dist/lsps1/contract.js.map +1 -0
- package/dist/memory/store.d.ts +7 -1
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +43 -3
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.d.ts +12 -0
- package/dist/memory/types.d.ts.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/kaleidoswap-atomic.d.ts +27 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.js +111 -0
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +13 -1
- package/dist/recipe/runner.js.map +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +20 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/wallet/confirm.d.ts +12 -0
- package/dist/wallet/confirm.d.ts.map +1 -0
- package/dist/wallet/confirm.js +67 -0
- package/dist/wallet/confirm.js.map +1 -0
- package/package.json +16 -1
- package/skills/README.md +6 -1
- package/skills/kaleido-lsps/SKILL.md +56 -0
- package/skills/kaleido-trading/SKILL.md +85 -18
- package/skills/merchant-finder/SKILL.md +87 -0
- package/skills/paid-data/SKILL.md +12 -0
- package/skills/wallet-assistant/SKILL.md +38 -0
- package/src/capabilities.ts +12 -0
- package/src/context/context.test.ts +6 -2
- package/src/engine.ts +6 -0
- package/src/funnel.ts +32 -7
- package/src/index.ts +43 -0
- package/src/kaleidoswap/contract.test.ts +147 -0
- package/src/kaleidoswap/contract.ts +212 -0
- package/src/knowledge/btc-map.test.ts +188 -0
- package/src/knowledge/btc-map.ts +446 -0
- package/src/lsps1/contract.test.ts +81 -0
- package/src/lsps1/contract.ts +132 -0
- package/src/memory/memory.test.ts +55 -0
- package/src/memory/store.ts +49 -4
- package/src/memory/types.ts +13 -0
- 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/kaleidoswap-atomic.test.ts +138 -0
- package/src/recipe/kaleidoswap-atomic.ts +117 -0
- package/src/recipe/runner.ts +13 -1
- package/src/skills/registry.ts +21 -2
- package/src/skills/skills.test.ts +42 -0
- package/src/wallet/confirm.test.ts +57 -0
- package/src/wallet/confirm.ts +74 -0
- package/skills/kaleido-wallet/SKILL.md +0 -28
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cleanAssistantVisibleText, sanitizeForSupertonic } from './text.js';
|
|
3
|
+
|
|
4
|
+
describe('cleanAssistantVisibleText', () => {
|
|
5
|
+
it('strips a closed <think> block', () => {
|
|
6
|
+
expect(cleanAssistantVisibleText('<think>plan the answer</think>Your balance is 5k sats.'))
|
|
7
|
+
.toBe('Your balance is 5k sats.');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('strips an unclosed <think> tail', () => {
|
|
11
|
+
expect(cleanAssistantVisibleText('Done.<think>still reasoning'))
|
|
12
|
+
.toBe('Done.');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('drops a leading tool-call prefix and keeps the sentence after it', () => {
|
|
16
|
+
// The heuristic strips up to `"arguments":` then one `{`. Cleanly recovers
|
|
17
|
+
// the tail when the model emits the framing without real JSON args.
|
|
18
|
+
const raw = '{"name":"get_balance","arguments": You have 5,000 sats.';
|
|
19
|
+
expect(cleanAssistantVisibleText(raw)).toBe('You have 5,000 sats.');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('is lossy when real JSON args follow (known heuristic limit, stray braces remain)', () => {
|
|
23
|
+
// Documents current behaviour verbatim from rate; a candidate for a future
|
|
24
|
+
// balanced-brace fix once it can be re-verified on device.
|
|
25
|
+
const raw = '{"name":"get_balance","arguments":{}} You have 5,000 sats.';
|
|
26
|
+
expect(cleanAssistantVisibleText(raw)).toBe('}} You have 5,000 sats.');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('collapses whitespace and trims', () => {
|
|
30
|
+
expect(cleanAssistantVisibleText(' hello world ')).toBe('hello world');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('leaves plain text untouched', () => {
|
|
34
|
+
expect(cleanAssistantVisibleText('Sent 3 EUR to bob.')).toBe('Sent 3 EUR to bob.');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('sanitizeForSupertonic', () => {
|
|
39
|
+
it('redacts a bolt11 lightning invoice', () => {
|
|
40
|
+
const out = sanitizeForSupertonic('Pay lnbc1' + 'q'.repeat(60) + ' now');
|
|
41
|
+
expect(out).toContain('Lightning invoice');
|
|
42
|
+
expect(out).not.toMatch(/lnbc1q/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('redacts an lnurl string', () => {
|
|
46
|
+
const out = sanitizeForSupertonic('Use lnurl1' + 'a'.repeat(50));
|
|
47
|
+
expect(out).toContain('Lightning payment link');
|
|
48
|
+
expect(out).not.toMatch(/lnurl1a/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('strips fenced code blocks and inline backticks', () => {
|
|
52
|
+
const out = sanitizeForSupertonic('Run ```rm -rf``` or `ls` here');
|
|
53
|
+
expect(out).not.toContain('`');
|
|
54
|
+
expect(out).toContain('ls');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('removes the backtick character (U+0060) entirely', () => {
|
|
58
|
+
const out = sanitizeForSupertonic('a`b`c');
|
|
59
|
+
expect(Array.from(out).some((ch) => ch.charCodeAt(0) === 0x60)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('normalizes smart quotes to ASCII', () => {
|
|
63
|
+
const out = sanitizeForSupertonic('“hello” ‘world’');
|
|
64
|
+
expect(out).toBe('"hello" \'world\'');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('drops non-ASCII and collapses whitespace', () => {
|
|
68
|
+
expect(sanitizeForSupertonic('café ✨ ok')).toBe('caf ok');
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/qvac/text.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text helpers for QVAC output. No SDK, no platform — safe to run and test
|
|
3
|
+
* anywhere. Lifted verbatim from rate's QVACService so every host shares one
|
|
4
|
+
* implementation instead of drifting copies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Clean a raw assistant completion into user-visible text:
|
|
9
|
+
* - drop `<think>…</think>` reasoning (small models leak it into content),
|
|
10
|
+
* - drop a leading `{"name":…,"arguments":…}` tool-call object some tiny models
|
|
11
|
+
* emit as plain text, keeping any natural-language sentence that follows.
|
|
12
|
+
*/
|
|
13
|
+
export function cleanAssistantVisibleText(text: string): string {
|
|
14
|
+
let cleaned = text
|
|
15
|
+
// Qwen-style reasoning sometimes arrives in contentText. Never show/speak it.
|
|
16
|
+
.replace(/<think\b[\s\S]*?<\/think>/gi, ' ')
|
|
17
|
+
.replace(/<think\b[\s\S]*$/gi, ' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
|
|
21
|
+
// Some small local models emit a tool-call object as plain text. Drop the
|
|
22
|
+
// leading fragment and keep any natural-language sentence that follows.
|
|
23
|
+
const toolPrefix = cleaned.match(/^\s*\{?\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*/i);
|
|
24
|
+
if (toolPrefix) {
|
|
25
|
+
cleaned = cleaned.slice(toolPrefix[0].length).replace(/^\s*\{?\s*/, '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return cleaned
|
|
29
|
+
.replace(/\s+/g, ' ')
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Make text safe for the SUPERTONIC TTS model: redact payment strings (so they
|
|
35
|
+
* are never read aloud), strip markdown/code, normalize smart punctuation, and
|
|
36
|
+
* drop any non-ASCII or backtick (U+0060) the model can't synthesize.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeForSupertonic(text: string): string {
|
|
39
|
+
const normalized = text
|
|
40
|
+
.replace(/\b(?:lightning:)?ln(?:bc|tb|bcrt)[a-z0-9]{40,}\b/gi, 'Lightning invoice')
|
|
41
|
+
.replace(/\blnurl[0-9a-z]{40,}\b/gi, 'Lightning payment link')
|
|
42
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
43
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
44
|
+
.replace(/[`´ˋ′*_~#<>|[\]{}]/g, ' ')
|
|
45
|
+
.replace(/[“”]/g, '"')
|
|
46
|
+
.replace(/[‘’]/g, "'")
|
|
47
|
+
.replace(/[•·]/g, '. ')
|
|
48
|
+
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, ' ')
|
|
49
|
+
.replace(/\s+/g, ' ');
|
|
50
|
+
|
|
51
|
+
return Array.from(normalized)
|
|
52
|
+
.filter((ch) => {
|
|
53
|
+
const code = ch.charCodeAt(0);
|
|
54
|
+
return (code === 0x09 || code === 0x0A || code === 0x0D || (code >= 0x20 && code <= 0x7E)) &&
|
|
55
|
+
code !== 0x60;
|
|
56
|
+
})
|
|
57
|
+
.join('')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
@@ -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,138 @@
|
|
|
1
|
+
import { describe, expect, it } 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 } from './runner.js';
|
|
6
|
+
import { kaleidoswapAtomicRecipe } from './kaleidoswap-atomic.js';
|
|
7
|
+
|
|
8
|
+
// LLM provider that should never be called when slots are extracted deterministically.
|
|
9
|
+
const refusingProvider: LLMProvider = {
|
|
10
|
+
name: 'refusing',
|
|
11
|
+
runTurn: async () => {
|
|
12
|
+
throw new Error('provider should NOT be called when extractSwap succeeds');
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Stub tools that record every call so we can assert the chain ran end-to-end.
|
|
17
|
+
function buildStubs(captured: { name: string; args: any }[]) {
|
|
18
|
+
const tool = (name: string, response: any, spend = false) => ({
|
|
19
|
+
name,
|
|
20
|
+
description: '',
|
|
21
|
+
parameters: { type: 'object', properties: {} },
|
|
22
|
+
requiresConfirmation: spend,
|
|
23
|
+
handler: async (a: any) => {
|
|
24
|
+
captured.push({ name, args: a });
|
|
25
|
+
return typeof response === 'function' ? response(a) : response;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return new ToolRegistry([
|
|
29
|
+
new InProcessToolSource('kaleidoswap', [
|
|
30
|
+
tool('kaleidoswap_get_quote', { quote_id: 'q-1', receive_amount: 100, fees: 250 }),
|
|
31
|
+
tool('kaleidoswap_atomic_init', { atomic_id: 'a-1', maker_invoice: 'lnbc1maker' }, /* spend */ true),
|
|
32
|
+
tool('kaleidoswap_atomic_execute', { status: 'completed' }, /* spend */ true),
|
|
33
|
+
]),
|
|
34
|
+
new InProcessToolSource('rln', [
|
|
35
|
+
tool('rln_create_rgb_invoice', { invoice: 'rgb:invoice:USDT:100' }),
|
|
36
|
+
tool('rln_create_ln_invoice', { invoice: 'lnbc1user' }),
|
|
37
|
+
tool('rln_pay_invoice', { status: 'SUCCESS', payment_hash: 'h' }, /* spend */ true),
|
|
38
|
+
]),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('kaleidoswapAtomicRecipe — selection (match + triggers)', () => {
|
|
43
|
+
it('triggers on explicit atomic-swap phrasings', () => {
|
|
44
|
+
expect(kaleidoswapAtomicRecipe.match!('atomic swap 100000 sats for usdt')).toBe(true);
|
|
45
|
+
expect(kaleidoswapAtomicRecipe.match!('trustless swap btc to usdt')).toBe(true);
|
|
46
|
+
expect(kaleidoswapAtomicRecipe.match!('htlc swap 1000 sats to USDT')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does NOT fire on a plain swap (those go to swapRecipe)', () => {
|
|
50
|
+
expect(kaleidoswapAtomicRecipe.match!('swap 10 usdt for btc')).toBe(false);
|
|
51
|
+
expect(kaleidoswapAtomicRecipe.match!('exchange 1000 sats for usdt')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('kaleidoswapAtomicRecipe — RGB receive leg', () => {
|
|
56
|
+
it('runs quote → rgb_invoice → atomic_init → pay → atomic_execute (one inference)', async () => {
|
|
57
|
+
const captured: { name: string; args: any }[] = [];
|
|
58
|
+
const tools = buildStubs(captured);
|
|
59
|
+
|
|
60
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
|
|
61
|
+
provider: refusingProvider,
|
|
62
|
+
tools,
|
|
63
|
+
onConfirm: async () => ({ approved: true }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(res.status).toBe('done');
|
|
67
|
+
expect(res.inferences).toBe(0); // extractSwap handled it deterministically
|
|
68
|
+
|
|
69
|
+
// The chain: quote → rgb_invoice → atomic_init → pay → atomic_execute (5 calls).
|
|
70
|
+
expect(captured.map((c) => c.name)).toEqual([
|
|
71
|
+
'kaleidoswap_get_quote',
|
|
72
|
+
'rln_create_rgb_invoice',
|
|
73
|
+
'kaleidoswap_atomic_init',
|
|
74
|
+
'rln_pay_invoice',
|
|
75
|
+
'kaleidoswap_atomic_execute',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// RGB invoice fed into atomic_init.
|
|
79
|
+
const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
|
|
80
|
+
expect(init.args).toEqual({ quote_id: 'q-1', receive_invoice: 'rgb:invoice:USDT:100' });
|
|
81
|
+
|
|
82
|
+
// Maker invoice fed into pay step.
|
|
83
|
+
const pay = captured.find((c) => c.name === 'rln_pay_invoice')!;
|
|
84
|
+
expect(pay.args).toEqual({ invoice: 'lnbc1maker' });
|
|
85
|
+
|
|
86
|
+
// Final execute carried the atomic id.
|
|
87
|
+
const exe = captured.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
|
|
88
|
+
expect(exe.args).toEqual({ atomic_id: 'a-1' });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('kaleidoswapAtomicRecipe — BTC receive leg', () => {
|
|
93
|
+
it('uses rln_create_ln_invoice (not rgb) when to_asset is BTC', async () => {
|
|
94
|
+
const captured: { name: string; args: any }[] = [];
|
|
95
|
+
const tools = buildStubs(captured);
|
|
96
|
+
|
|
97
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100 usdt for btc', {
|
|
98
|
+
provider: refusingProvider,
|
|
99
|
+
tools,
|
|
100
|
+
onConfirm: async () => ({ approved: true }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe('done');
|
|
104
|
+
expect(captured.map((c) => c.name)).toEqual([
|
|
105
|
+
'kaleidoswap_get_quote',
|
|
106
|
+
'rln_create_ln_invoice',
|
|
107
|
+
'kaleidoswap_atomic_init',
|
|
108
|
+
'rln_pay_invoice',
|
|
109
|
+
'kaleidoswap_atomic_execute',
|
|
110
|
+
]);
|
|
111
|
+
const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
|
|
112
|
+
expect(init.args.receive_invoice).toBe('lnbc1user');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('kaleidoswapAtomicRecipe — confirmation gate', () => {
|
|
117
|
+
it('cancels the chain when the user declines a spend gate', async () => {
|
|
118
|
+
const captured: { name: string; args: any }[] = [];
|
|
119
|
+
const tools = buildStubs(captured);
|
|
120
|
+
let firstSpendSeen = false;
|
|
121
|
+
|
|
122
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
|
|
123
|
+
provider: refusingProvider,
|
|
124
|
+
tools,
|
|
125
|
+
onConfirm: async () => {
|
|
126
|
+
if (firstSpendSeen) return { approved: true };
|
|
127
|
+
firstSpendSeen = true;
|
|
128
|
+
return { approved: false, reason: 'user said no' };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(res.status).not.toBe('done');
|
|
133
|
+
// The first spend tool (atomic_init) should NOT have completed successfully —
|
|
134
|
+
// the chain stops before pay/execute.
|
|
135
|
+
expect(captured.some((c) => c.name === 'rln_pay_invoice')).toBe(false);
|
|
136
|
+
expect(captured.some((c) => c.name === 'kaleidoswap_atomic_execute')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in "atomic swap on KaleidoSwap" recipe — trust-minimised chain.
|
|
3
|
+
*
|
|
4
|
+
* Most users want the simple market-order swap (`swapRecipe` over generic
|
|
5
|
+
* `get_swap_quote` / `execute_swap`). This recipe is the EXPLICIT atomic path:
|
|
6
|
+
* the user creates an RGB/LN receive invoice, the maker locks the swap, the
|
|
7
|
+
* user pays the maker's Lightning invoice, and the maker releases.
|
|
8
|
+
*
|
|
9
|
+
* Triggered only by explicit atomic-swap intent ("atomic swap", "trustless
|
|
10
|
+
* swap", "htlc swap") so it never preempts the simpler swap path for vague
|
|
11
|
+
* phrasings.
|
|
12
|
+
*
|
|
13
|
+
* "atomic swap 100000 sats for usdt"
|
|
14
|
+
* ↓ 1 model inference (slot extraction)
|
|
15
|
+
* kaleidoswap_get_quote ← maker prices the swap
|
|
16
|
+
* rln_create_rgb_invoice ← user's node prepares receive (if to_asset is RGB)
|
|
17
|
+
* rln_create_ln_invoice ← (alt) if to_asset is BTC
|
|
18
|
+
* kaleidoswap_atomic_init 🔒 ← maker locks the swap, returns its invoice
|
|
19
|
+
* rln_pay_invoice 🔒 ← user pays the maker
|
|
20
|
+
* kaleidoswap_atomic_execute 🔒 ← (final) maker releases the asset
|
|
21
|
+
*
|
|
22
|
+
* Two-or-three confirmation gates are intentional: each represents a distinct
|
|
23
|
+
* decision point. The host's confirm UI describes what's about to happen.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Recipe } from './types.js';
|
|
27
|
+
import { extractSwap } from './swap.js';
|
|
28
|
+
|
|
29
|
+
const ATOMIC_INTENT =
|
|
30
|
+
/\b(atomic|trustless|htlc)\b.*\b(swap|exchange|convert|trade)\b|\b(swap|exchange|convert|trade)\b.*\b(atomic|trustless|htlc)\b/i;
|
|
31
|
+
|
|
32
|
+
function isBtc(asset: unknown): boolean {
|
|
33
|
+
return String(asset ?? '').toUpperCase() === 'BTC';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const kaleidoswapAtomicRecipe: Recipe = {
|
|
37
|
+
name: 'kaleidoswap-atomic',
|
|
38
|
+
description:
|
|
39
|
+
'Trust-minimised atomic swap on KaleidoSwap: quote, prepare a receive invoice on the user\'s node, lock the swap with the maker, pay, and execute.',
|
|
40
|
+
match: (t) => ATOMIC_INTENT.test(t),
|
|
41
|
+
triggers: ['atomic swap', 'trustless swap', 'htlc swap'],
|
|
42
|
+
slots: [
|
|
43
|
+
{ name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT)', required: true },
|
|
44
|
+
{ name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT)', required: true },
|
|
45
|
+
{ name: 'amount', type: 'number', description: 'Amount of from_asset to swap' },
|
|
46
|
+
],
|
|
47
|
+
extract: extractSwap,
|
|
48
|
+
confident: (s) => !!s.from_asset && !!s.to_asset && !!s.amount,
|
|
49
|
+
steps: [
|
|
50
|
+
// 1. Maker quotes the swap. Returns { quote_id, receive_amount, fees, ttl_ms, ... }.
|
|
51
|
+
{
|
|
52
|
+
tool: 'kaleidoswap_get_quote',
|
|
53
|
+
as: 'quote',
|
|
54
|
+
args: (ctx) => ({
|
|
55
|
+
from_asset: ctx.slots.from_asset,
|
|
56
|
+
to_asset: ctx.slots.to_asset,
|
|
57
|
+
amount: ctx.slots.amount,
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
// 2a. User's node creates an RGB receive invoice (when to_asset is an RGB asset).
|
|
61
|
+
{
|
|
62
|
+
tool: 'rln_create_rgb_invoice',
|
|
63
|
+
as: 'receive_rgb',
|
|
64
|
+
args: (ctx) => {
|
|
65
|
+
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
66
|
+
return { asset: ctx.slots.to_asset, amount: q?.receive_amount };
|
|
67
|
+
},
|
|
68
|
+
skipIf: (ctx) => isBtc(ctx.slots.to_asset),
|
|
69
|
+
},
|
|
70
|
+
// 2b. User's node creates an LN receive invoice (when to_asset is BTC).
|
|
71
|
+
{
|
|
72
|
+
tool: 'rln_create_ln_invoice',
|
|
73
|
+
as: 'receive_ln',
|
|
74
|
+
args: (ctx) => {
|
|
75
|
+
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
76
|
+
return { amount_sats: q?.receive_amount };
|
|
77
|
+
},
|
|
78
|
+
skipIf: (ctx) => !isBtc(ctx.slots.to_asset),
|
|
79
|
+
},
|
|
80
|
+
// 3. Maker locks the swap. Returns { atomic_id, maker_invoice }. Spend-gated.
|
|
81
|
+
{
|
|
82
|
+
tool: 'kaleidoswap_atomic_init',
|
|
83
|
+
as: 'atomic',
|
|
84
|
+
args: (ctx) => {
|
|
85
|
+
const rgb = ctx.results.receive_rgb as { invoice?: string } | undefined;
|
|
86
|
+
const ln = ctx.results.receive_ln as { invoice?: string } | undefined;
|
|
87
|
+
const q = ctx.results.quote as { quote_id?: string } | undefined;
|
|
88
|
+
return {
|
|
89
|
+
quote_id: q?.quote_id,
|
|
90
|
+
receive_invoice: rgb?.invoice ?? ln?.invoice,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
// 4. User pays the maker's Lightning invoice. Spend-gated by the wallet contract.
|
|
95
|
+
{
|
|
96
|
+
tool: 'rln_pay_invoice',
|
|
97
|
+
as: 'paid',
|
|
98
|
+
args: (ctx) => {
|
|
99
|
+
const a = ctx.results.atomic as { maker_invoice?: string } | undefined;
|
|
100
|
+
return { invoice: a?.maker_invoice };
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
// 5. Maker releases the receive asset → swap completes. Spend-gated.
|
|
105
|
+
final: {
|
|
106
|
+
tool: 'kaleidoswap_atomic_execute',
|
|
107
|
+
args: (ctx) => {
|
|
108
|
+
const a = ctx.results.atomic as { atomic_id?: string } | undefined;
|
|
109
|
+
return { atomic_id: a?.atomic_id };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
summary: (ctx) => {
|
|
113
|
+
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
114
|
+
const tail = q?.receive_amount ? ` ≈ ${q.receive_amount} ${ctx.slots.to_asset}` : '';
|
|
115
|
+
return `Atomic swap: ${ctx.slots.amount} ${ctx.slots.from_asset} → ${ctx.slots.to_asset}${tail}.`;
|
|
116
|
+
},
|
|
117
|
+
};
|
package/src/recipe/runner.ts
CHANGED
|
@@ -61,10 +61,22 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
61
61
|
inferences = ex.inferences;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Deterministic steps.
|
|
64
|
+
// Deterministic steps. Intermediate spend tools fire the same confirmation
|
|
65
|
+
// gate as the final step — recipes with multi-spend chains (e.g. atomic
|
|
66
|
+
// swaps) MUST have every money-moving call gated, never just the last one.
|
|
67
|
+
// Missing onConfirm fails closed, matching the Engine.
|
|
65
68
|
for (const step of recipe.steps) {
|
|
66
69
|
if (step.skipIf?.(ctx)) continue;
|
|
67
70
|
const args = step.args(ctx);
|
|
71
|
+
const def = await opts.tools.getDef(step.tool);
|
|
72
|
+
if (def?.requiresConfirmation) {
|
|
73
|
+
const decision = opts.onConfirm
|
|
74
|
+
? await opts.onConfirm({ name: step.tool, arguments: args })
|
|
75
|
+
: { approved: false, reason: 'no confirmation handler available' };
|
|
76
|
+
if (!decision.approved) {
|
|
77
|
+
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: 'Cancelled — nothing was sent.', status: 'cancelled', inferences };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
const result = await opts.tools.execute(step.tool, args);
|
|
69
81
|
ctx.results[step.as ?? step.tool] = result;
|
|
70
82
|
opts.onStep?.(step.tool, args, result);
|