@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.
Files changed (130) hide show
  1. package/dist/capabilities.d.ts +4 -0
  2. package/dist/capabilities.d.ts.map +1 -1
  3. package/dist/capabilities.js +7 -0
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/engine.d.ts +9 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +1 -0
  8. package/dist/engine.js.map +1 -1
  9. package/dist/funnel.d.ts +6 -0
  10. package/dist/funnel.d.ts.map +1 -1
  11. package/dist/funnel.js +26 -6
  12. package/dist/funnel.js.map +1 -1
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/kaleidoswap/contract.d.ts +72 -0
  18. package/dist/kaleidoswap/contract.d.ts.map +1 -0
  19. package/dist/kaleidoswap/contract.js +125 -0
  20. package/dist/kaleidoswap/contract.js.map +1 -0
  21. package/dist/knowledge/btc-map.d.ts +87 -0
  22. package/dist/knowledge/btc-map.d.ts.map +1 -0
  23. package/dist/knowledge/btc-map.js +365 -0
  24. package/dist/knowledge/btc-map.js.map +1 -0
  25. package/dist/lsps1/contract.d.ts +55 -0
  26. package/dist/lsps1/contract.d.ts.map +1 -0
  27. package/dist/lsps1/contract.js +91 -0
  28. package/dist/lsps1/contract.js.map +1 -0
  29. package/dist/memory/store.d.ts +7 -1
  30. package/dist/memory/store.d.ts.map +1 -1
  31. package/dist/memory/store.js +43 -3
  32. package/dist/memory/store.js.map +1 -1
  33. package/dist/memory/types.d.ts +12 -0
  34. package/dist/memory/types.d.ts.map +1 -1
  35. package/dist/qvac/assistant.d.ts +73 -0
  36. package/dist/qvac/assistant.d.ts.map +1 -0
  37. package/dist/qvac/assistant.js +97 -0
  38. package/dist/qvac/assistant.js.map +1 -0
  39. package/dist/qvac/config.d.ts +64 -0
  40. package/dist/qvac/config.d.ts.map +1 -0
  41. package/dist/qvac/config.js +71 -0
  42. package/dist/qvac/config.js.map +1 -0
  43. package/dist/qvac/delegate.d.ts +48 -0
  44. package/dist/qvac/delegate.d.ts.map +1 -0
  45. package/dist/qvac/delegate.js +51 -0
  46. package/dist/qvac/delegate.js.map +1 -0
  47. package/dist/qvac/index.d.ts +19 -0
  48. package/dist/qvac/index.d.ts.map +1 -0
  49. package/dist/qvac/index.js +19 -0
  50. package/dist/qvac/index.js.map +1 -0
  51. package/dist/qvac/parse.d.ts +44 -0
  52. package/dist/qvac/parse.d.ts.map +1 -0
  53. package/dist/qvac/parse.js +28 -0
  54. package/dist/qvac/parse.js.map +1 -0
  55. package/dist/qvac/provider.d.ts +49 -0
  56. package/dist/qvac/provider.d.ts.map +1 -0
  57. package/dist/qvac/provider.js +68 -0
  58. package/dist/qvac/provider.js.map +1 -0
  59. package/dist/qvac/stream.d.ts +37 -0
  60. package/dist/qvac/stream.d.ts.map +1 -0
  61. package/dist/qvac/stream.js +29 -0
  62. package/dist/qvac/stream.js.map +1 -0
  63. package/dist/qvac/text.d.ts +19 -0
  64. package/dist/qvac/text.d.ts.map +1 -0
  65. package/dist/qvac/text.js +56 -0
  66. package/dist/qvac/text.js.map +1 -0
  67. package/dist/qvac/voice.d.ts +69 -0
  68. package/dist/qvac/voice.d.ts.map +1 -0
  69. package/dist/qvac/voice.js +51 -0
  70. package/dist/qvac/voice.js.map +1 -0
  71. package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
  72. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
  73. package/dist/recipe/kaleidoswap-atomic.js +111 -0
  74. package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
  75. package/dist/recipe/runner.d.ts.map +1 -1
  76. package/dist/recipe/runner.js +13 -1
  77. package/dist/recipe/runner.js.map +1 -1
  78. package/dist/skills/registry.d.ts.map +1 -1
  79. package/dist/skills/registry.js +20 -2
  80. package/dist/skills/registry.js.map +1 -1
  81. package/dist/wallet/confirm.d.ts +12 -0
  82. package/dist/wallet/confirm.d.ts.map +1 -0
  83. package/dist/wallet/confirm.js +67 -0
  84. package/dist/wallet/confirm.js.map +1 -0
  85. package/package.json +16 -1
  86. package/skills/README.md +6 -1
  87. package/skills/kaleido-lsps/SKILL.md +56 -0
  88. package/skills/kaleido-trading/SKILL.md +85 -18
  89. package/skills/merchant-finder/SKILL.md +87 -0
  90. package/skills/paid-data/SKILL.md +12 -0
  91. package/skills/wallet-assistant/SKILL.md +38 -0
  92. package/src/capabilities.ts +12 -0
  93. package/src/context/context.test.ts +6 -2
  94. package/src/engine.ts +6 -0
  95. package/src/funnel.ts +32 -7
  96. package/src/index.ts +43 -0
  97. package/src/kaleidoswap/contract.test.ts +147 -0
  98. package/src/kaleidoswap/contract.ts +212 -0
  99. package/src/knowledge/btc-map.test.ts +188 -0
  100. package/src/knowledge/btc-map.ts +446 -0
  101. package/src/lsps1/contract.test.ts +81 -0
  102. package/src/lsps1/contract.ts +132 -0
  103. package/src/memory/memory.test.ts +55 -0
  104. package/src/memory/store.ts +49 -4
  105. package/src/memory/types.ts +13 -0
  106. package/src/qvac/assistant.test.ts +132 -0
  107. package/src/qvac/assistant.ts +146 -0
  108. package/src/qvac/config.test.ts +44 -0
  109. package/src/qvac/config.ts +76 -0
  110. package/src/qvac/delegate.test.ts +68 -0
  111. package/src/qvac/delegate.ts +71 -0
  112. package/src/qvac/index.ts +72 -0
  113. package/src/qvac/parse.test.ts +52 -0
  114. package/src/qvac/parse.ts +57 -0
  115. package/src/qvac/provider.test.ts +107 -0
  116. package/src/qvac/provider.ts +124 -0
  117. package/src/qvac/stream.test.ts +79 -0
  118. package/src/qvac/stream.ts +56 -0
  119. package/src/qvac/text.test.ts +70 -0
  120. package/src/qvac/text.ts +60 -0
  121. package/src/qvac/voice.test.ts +151 -0
  122. package/src/qvac/voice.ts +122 -0
  123. package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
  124. package/src/recipe/kaleidoswap-atomic.ts +117 -0
  125. package/src/recipe/runner.ts +13 -1
  126. package/src/skills/registry.ts +21 -2
  127. package/src/skills/skills.test.ts +42 -0
  128. package/src/wallet/confirm.test.ts +57 -0
  129. package/src/wallet/confirm.ts +74 -0
  130. 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
+ });
@@ -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
+ };
@@ -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);