@kaleidorg/mind 0.3.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 (54) hide show
  1. package/dist/qvac/assistant.d.ts +73 -0
  2. package/dist/qvac/assistant.d.ts.map +1 -0
  3. package/dist/qvac/assistant.js +97 -0
  4. package/dist/qvac/assistant.js.map +1 -0
  5. package/dist/qvac/config.d.ts +64 -0
  6. package/dist/qvac/config.d.ts.map +1 -0
  7. package/dist/qvac/config.js +71 -0
  8. package/dist/qvac/config.js.map +1 -0
  9. package/dist/qvac/delegate.d.ts +48 -0
  10. package/dist/qvac/delegate.d.ts.map +1 -0
  11. package/dist/qvac/delegate.js +51 -0
  12. package/dist/qvac/delegate.js.map +1 -0
  13. package/dist/qvac/index.d.ts +19 -0
  14. package/dist/qvac/index.d.ts.map +1 -0
  15. package/dist/qvac/index.js +19 -0
  16. package/dist/qvac/index.js.map +1 -0
  17. package/dist/qvac/parse.d.ts +44 -0
  18. package/dist/qvac/parse.d.ts.map +1 -0
  19. package/dist/qvac/parse.js +28 -0
  20. package/dist/qvac/parse.js.map +1 -0
  21. package/dist/qvac/provider.d.ts +49 -0
  22. package/dist/qvac/provider.d.ts.map +1 -0
  23. package/dist/qvac/provider.js +68 -0
  24. package/dist/qvac/provider.js.map +1 -0
  25. package/dist/qvac/stream.d.ts +37 -0
  26. package/dist/qvac/stream.d.ts.map +1 -0
  27. package/dist/qvac/stream.js +29 -0
  28. package/dist/qvac/stream.js.map +1 -0
  29. package/dist/qvac/text.d.ts +19 -0
  30. package/dist/qvac/text.d.ts.map +1 -0
  31. package/dist/qvac/text.js +56 -0
  32. package/dist/qvac/text.js.map +1 -0
  33. package/dist/qvac/voice.d.ts +69 -0
  34. package/dist/qvac/voice.d.ts.map +1 -0
  35. package/dist/qvac/voice.js +51 -0
  36. package/dist/qvac/voice.js.map +1 -0
  37. package/package.json +15 -1
  38. package/src/qvac/assistant.test.ts +132 -0
  39. package/src/qvac/assistant.ts +146 -0
  40. package/src/qvac/config.test.ts +44 -0
  41. package/src/qvac/config.ts +76 -0
  42. package/src/qvac/delegate.test.ts +68 -0
  43. package/src/qvac/delegate.ts +71 -0
  44. package/src/qvac/index.ts +72 -0
  45. package/src/qvac/parse.test.ts +52 -0
  46. package/src/qvac/parse.ts +57 -0
  47. package/src/qvac/provider.test.ts +107 -0
  48. package/src/qvac/provider.ts +124 -0
  49. package/src/qvac/stream.test.ts +79 -0
  50. package/src/qvac/stream.ts +56 -0
  51. package/src/qvac/text.test.ts +70 -0
  52. package/src/qvac/text.ts +60 -0
  53. package/src/qvac/voice.test.ts +151 -0
  54. package/src/qvac/voice.ts +122 -0
@@ -0,0 +1,68 @@
1
+ import { consumeRun } from './stream.js';
2
+ export function createQvacProvider(options) {
3
+ return {
4
+ name: 'qvac',
5
+ async runTurn(input) {
6
+ const modelId = options.getModelId();
7
+ if (!modelId)
8
+ throw new Error('QVAC model not loaded');
9
+ const history = input.system
10
+ ? [{ role: 'system', content: input.system }, ...input.messages]
11
+ : input.messages;
12
+ // Tools are forwarded by schema only (name/description/parameters). We
13
+ // carry `parameters` through verbatim (Zod for in-process tools, JSON
14
+ // Schema for MCP) — the model only needs the shape to pick a call; the
15
+ // Engine validates + executes.
16
+ const tools = input.tools.length
17
+ ? input.tools.map((t) => ({
18
+ name: t.name,
19
+ description: t.description,
20
+ parameters: t.parameters,
21
+ }))
22
+ : undefined;
23
+ // QVAC 0.13 nests sampling under `generationParams`; top-level
24
+ // `temperature`/`max_tokens` (as older rate code passed) are dropped by
25
+ // validation, so the cap silently no-op'd. Build it here, and only send it
26
+ // when a value is set so a host that passes neither keeps SDK defaults.
27
+ const temp = input.temperature ?? options.defaultTemperature;
28
+ const predict = input.maxTokens ?? options.defaultMaxTokens;
29
+ const generationParams = temp !== undefined || predict !== undefined
30
+ ? {
31
+ ...(temp !== undefined ? { temp } : {}),
32
+ ...(predict !== undefined ? { predict } : {}),
33
+ }
34
+ : undefined;
35
+ const run = options.completion({
36
+ modelId,
37
+ history,
38
+ stream: true,
39
+ // Split `<think>` into separate thinkingDelta events so reasoning never
40
+ // pollutes the visible answer.
41
+ captureThinking: true,
42
+ ...(generationParams ? { generationParams } : {}),
43
+ ...(tools ? { tools } : {}),
44
+ });
45
+ const result = await consumeRun(run, {
46
+ onToken: input.onToken,
47
+ onThinking: input.onThinking ?? options.onThinking,
48
+ });
49
+ return {
50
+ text: result.text,
51
+ rawContent: result.rawContent,
52
+ toolCalls: result.toolCalls,
53
+ requestId: result.requestId,
54
+ };
55
+ },
56
+ async cancel(requestId) {
57
+ // The cancel only lands once the server has begun the request; a same-tick
58
+ // cancel may race the begin and is logged as a no-match by the SDK.
59
+ try {
60
+ await options.cancel({ requestId });
61
+ }
62
+ catch (err) {
63
+ console.warn('[qvac] cancel failed:', err);
64
+ }
65
+ },
66
+ };
67
+ }
68
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../../src/qvac/provider.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Consume a QVAC `completion()` run: drain the event stream (forwarding visible
3
+ * + thinking tokens) and fold the `final` frame into a ParsedTurn.
4
+ *
5
+ * Defined over a structural `CompletionRunLike` (not the SDK type) so it stays
6
+ * SDK-free and unit-testable with a fake run — the real `CompletionRun` is
7
+ * assignable to it. The actual `@qvac/sdk` import lives in `provider.ts`.
8
+ */
9
+ import { type ParsedTurn, type QvacFinalLike } from './parse.js';
10
+ /** Minimal shape of a QVAC completion event we react to. */
11
+ export interface CompletionEventLike {
12
+ type: string;
13
+ /** Present on `contentDelta` / `thinkingDelta` / `rawDelta`. */
14
+ text?: string;
15
+ }
16
+ /** Structural subset of `completion()`'s return we depend on. */
17
+ export interface CompletionRunLike {
18
+ requestId: string;
19
+ events: AsyncIterable<CompletionEventLike>;
20
+ final: Promise<QvacFinalLike>;
21
+ }
22
+ export interface StreamHandlers {
23
+ /** Visible assistant tokens (excludes `<think>` reasoning). */
24
+ onToken?: (token: string) => void;
25
+ /** The model's `<think>` reasoning, streamed separately. */
26
+ onThinking?: (token: string) => void;
27
+ }
28
+ export interface ConsumedTurn extends ParsedTurn {
29
+ requestId: string;
30
+ }
31
+ /**
32
+ * Stream a run to completion. `contentDelta` → onToken (and the streamed
33
+ * fallback text), `thinkingDelta` → onThinking. Returns the parsed turn plus the
34
+ * run's `requestId` (for cancellation bookkeeping by the caller).
35
+ */
36
+ export declare function consumeRun(run: CompletionRunLike, handlers?: StreamHandlers): Promise<ConsumedTurn>;
37
+ //# sourceMappingURL=stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/qvac/stream.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAe,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAE9E,4DAA4D;AAC5D,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,iEAAiE;AACjE,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC,mBAAmB,CAAC,CAAC;IAC3C,KAAK,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,4DAA4D;IAC5D,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,iBAAiB,EACtB,QAAQ,GAAE,cAAmB,GAC5B,OAAO,CAAC,YAAY,CAAC,CAYvB"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Consume a QVAC `completion()` run: drain the event stream (forwarding visible
3
+ * + thinking tokens) and fold the `final` frame into a ParsedTurn.
4
+ *
5
+ * Defined over a structural `CompletionRunLike` (not the SDK type) so it stays
6
+ * SDK-free and unit-testable with a fake run — the real `CompletionRun` is
7
+ * assignable to it. The actual `@qvac/sdk` import lives in `provider.ts`.
8
+ */
9
+ import { finalToTurn } from './parse.js';
10
+ /**
11
+ * Stream a run to completion. `contentDelta` → onToken (and the streamed
12
+ * fallback text), `thinkingDelta` → onThinking. Returns the parsed turn plus the
13
+ * run's `requestId` (for cancellation bookkeeping by the caller).
14
+ */
15
+ export async function consumeRun(run, handlers = {}) {
16
+ let streamed = '';
17
+ for await (const event of run.events) {
18
+ if (event.type === 'contentDelta' && typeof event.text === 'string') {
19
+ streamed += event.text;
20
+ handlers.onToken?.(event.text);
21
+ }
22
+ else if (event.type === 'thinkingDelta' && typeof event.text === 'string') {
23
+ handlers.onThinking?.(event.text);
24
+ }
25
+ }
26
+ const final = await run.final;
27
+ return { ...finalToTurn(final, streamed), requestId: run.requestId };
28
+ }
29
+ //# sourceMappingURL=stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/qvac/stream.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAuC,MAAM,YAAY,CAAC;AA2B9E;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,GAAsB,EACtB,WAA2B,EAAE;IAE7B,IAAI,QAAQ,GAAG,EAAE,CAAC;IAClB,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpE,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC;YACvB,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACjC,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,eAAe,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC5E,QAAQ,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC;IAC9B,OAAO,EAAE,GAAG,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC;AACvE,CAAC"}
@@ -0,0 +1,19 @@
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
+ * Clean a raw assistant completion into user-visible text:
8
+ * - drop `<think>…</think>` reasoning (small models leak it into content),
9
+ * - drop a leading `{"name":…,"arguments":…}` tool-call object some tiny models
10
+ * emit as plain text, keeping any natural-language sentence that follows.
11
+ */
12
+ export declare function cleanAssistantVisibleText(text: string): string;
13
+ /**
14
+ * Make text safe for the SUPERTONIC TTS model: redact payment strings (so they
15
+ * are never read aloud), strip markdown/code, normalize smart punctuation, and
16
+ * drop any non-ASCII or backtick (U+0060) the model can't synthesize.
17
+ */
18
+ export declare function sanitizeForSupertonic(text: string): string;
19
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/qvac/text.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAkB9D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAsB1D"}
@@ -0,0 +1,56 @@
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
+ * Clean a raw assistant completion into user-visible text:
8
+ * - drop `<think>…</think>` reasoning (small models leak it into content),
9
+ * - drop a leading `{"name":…,"arguments":…}` tool-call object some tiny models
10
+ * emit as plain text, keeping any natural-language sentence that follows.
11
+ */
12
+ export function cleanAssistantVisibleText(text) {
13
+ let cleaned = text
14
+ // Qwen-style reasoning sometimes arrives in contentText. Never show/speak it.
15
+ .replace(/<think\b[\s\S]*?<\/think>/gi, ' ')
16
+ .replace(/<think\b[\s\S]*$/gi, ' ')
17
+ .replace(/\s+/g, ' ')
18
+ .trim();
19
+ // Some small local models emit a tool-call object as plain text. Drop the
20
+ // leading fragment and keep any natural-language sentence that follows.
21
+ const toolPrefix = cleaned.match(/^\s*\{?\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*/i);
22
+ if (toolPrefix) {
23
+ cleaned = cleaned.slice(toolPrefix[0].length).replace(/^\s*\{?\s*/, '').trim();
24
+ }
25
+ return cleaned
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+ /**
30
+ * Make text safe for the SUPERTONIC TTS model: redact payment strings (so they
31
+ * are never read aloud), strip markdown/code, normalize smart punctuation, and
32
+ * drop any non-ASCII or backtick (U+0060) the model can't synthesize.
33
+ */
34
+ export function sanitizeForSupertonic(text) {
35
+ const normalized = text
36
+ .replace(/\b(?:lightning:)?ln(?:bc|tb|bcrt)[a-z0-9]{40,}\b/gi, 'Lightning invoice')
37
+ .replace(/\blnurl[0-9a-z]{40,}\b/gi, 'Lightning payment link')
38
+ .replace(/```[\s\S]*?```/g, ' ')
39
+ .replace(/`([^`]*)`/g, '$1')
40
+ .replace(/[`´ˋ′*_~#<>|[\]{}]/g, ' ')
41
+ .replace(/[“”]/g, '"')
42
+ .replace(/[‘’]/g, "'")
43
+ .replace(/[•·]/g, '. ')
44
+ .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, ' ')
45
+ .replace(/\s+/g, ' ');
46
+ return Array.from(normalized)
47
+ .filter((ch) => {
48
+ const code = ch.charCodeAt(0);
49
+ return (code === 0x09 || code === 0x0A || code === 0x0D || (code >= 0x20 && code <= 0x7E)) &&
50
+ code !== 0x60;
51
+ })
52
+ .join('')
53
+ .replace(/\s+/g, ' ')
54
+ .trim();
55
+ }
56
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.js","sourceRoot":"","sources":["../../src/qvac/text.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB,CAAC,IAAY;IACpD,IAAI,OAAO,GAAG,IAAI;QAChB,8EAA8E;SAC7E,OAAO,CAAC,6BAA6B,EAAE,GAAG,CAAC;SAC3C,OAAO,CAAC,oBAAoB,EAAE,GAAG,CAAC;SAClC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;SACpB,IAAI,EAAE,CAAC;IAEV,0EAA0E;IAC1E,wEAAwE;IACxE,MAAM,UAAU,GAAG,OAAO,CAAC,KAAK,CAAC,0DAA0D,CAAC,CAAC;IAC7F,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACjF,CAAC;IAED,OAAO,OAAO;SACX,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;SACpB,IAAI,EAAE,CAAC;AACZ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY;IAChD,MAAM,UAAU,GAAG,IAAI;SACpB,OAAO,CAAC,oDAAoD,EAAE,mBAAmB,CAAC;SAClF,OAAO,CAAC,0BAA0B,EAAE,wBAAwB,CAAC;SAC7D,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC;SAC/B,OAAO,CAAC,YAAY,EAAE,IAAI,CAAC;SAC3B,OAAO,CAAC,qBAAqB,EAAE,GAAG,CAAC;SACnC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;SACrB,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;SACrB,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC;SACtB,OAAO,CAAC,2BAA2B,EAAE,GAAG,CAAC;SACzC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAExB,OAAO,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC;SAC1B,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE;QACb,MAAM,IAAI,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC9B,OAAO,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,CAAC,CAAC;YACxF,IAAI,KAAK,IAAI,CAAC;IAClB,CAAC,CAAC;SACD,IAAI,CAAC,EAAE,CAAC;SACR,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;SACpB,IAAI,EAAE,CAAC;AACZ,CAAC"}
@@ -0,0 +1,69 @@
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 type { VoiceTranscriptEvent } from './assistant.js';
17
+ type TranscribeFn = typeof QvacSdk.transcribe;
18
+ type TextToSpeechFn = typeof QvacSdk.textToSpeech;
19
+ type TranscribeStreamFn = typeof QvacSdk.transcribeStream;
20
+ /** 16-bit PCM samples plus their sample rate, ready for the host to play. */
21
+ export interface PcmAudio {
22
+ pcm: number[];
23
+ sampleRate: number;
24
+ }
25
+ /**
26
+ * A live VAD transcription session: feed mic audio with `write()`, iterate to
27
+ * receive `text`/`vad`/`endOfTurn` events, `end()` when audio stops. Pass it
28
+ * straight to `runVoiceAssistant`.
29
+ */
30
+ export interface VoiceSession {
31
+ write(audioChunk: Uint8Array): void;
32
+ end(): void;
33
+ destroy(): void;
34
+ [Symbol.asyncIterator](): AsyncIterator<VoiceTranscriptEvent>;
35
+ }
36
+ export interface QvacVoiceOptions {
37
+ /** The SDK's `transcribe` (injected). */
38
+ transcribe: TranscribeFn;
39
+ /** The SDK's `textToSpeech` (injected). */
40
+ textToSpeech: TextToSpeechFn;
41
+ /** The SDK's `transcribeStream` (injected) — only needed for `openVoiceSession`. */
42
+ transcribeStream?: TranscribeStreamFn;
43
+ /** Resolve the loaded Whisper model id (null ⇒ not loaded → throws). */
44
+ getWhisperModelId: () => string | null;
45
+ /** Resolve the loaded TTS model id (null ⇒ not loaded → returns null). */
46
+ getTtsModelId: () => string | null;
47
+ /** TTS output sample rate; defaults to SUPERTONIC-2's 44.1 kHz. */
48
+ ttsSampleRate?: number;
49
+ }
50
+ export interface QvacVoice {
51
+ /** Transcribe an audio file (path or `file://` URI) to text. */
52
+ transcribeAudio(audioUri: string): Promise<string>;
53
+ /**
54
+ * Synthesize speech for `text`. Returns PCM + sample rate, or `null` when TTS
55
+ * is unavailable or the text is empty after sanitization (host falls back to
56
+ * the system voice). Payment strings are redacted so they're never read aloud.
57
+ */
58
+ synthesizeSpeech(text: string): Promise<PcmAudio | null>;
59
+ /**
60
+ * Open a hands-free VAD transcription session (continuous voice). Requires
61
+ * `transcribeStream` to have been provided. Merge in `paramsOverride` to tune
62
+ * the defaults ({@link DEFAULT_VOICE_STREAM_PARAMS}). Feed the returned session
63
+ * to `runVoiceAssistant`.
64
+ */
65
+ openVoiceSession(paramsOverride?: Record<string, unknown>): Promise<VoiceSession>;
66
+ }
67
+ export declare function createQvacVoice(options: QvacVoiceOptions): QvacVoice;
68
+ export {};
69
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../../src/qvac/voice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,KAAK,OAAO,MAAM,WAAW,CAAC;AAG1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE3D,KAAK,YAAY,GAAG,OAAO,OAAO,CAAC,UAAU,CAAC;AAC9C,KAAK,cAAc,GAAG,OAAO,OAAO,CAAC,YAAY,CAAC;AAClD,KAAK,kBAAkB,GAAG,OAAO,OAAO,CAAC,gBAAgB,CAAC;AAE1D,6EAA6E;AAC7E,MAAM,WAAW,QAAQ;IACvB,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,GAAG,IAAI,IAAI,CAAC;IACZ,OAAO,IAAI,IAAI,CAAC;IAChB,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,oBAAoB,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,YAAY,CAAC;IACzB,2CAA2C;IAC3C,YAAY,EAAE,cAAc,CAAC;IAC7B,oFAAoF;IACpF,gBAAgB,CAAC,EAAE,kBAAkB,CAAC;IACtC,wEAAwE;IACxE,iBAAiB,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACvC,0EAA0E;IAC1E,aAAa,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACnC,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,gEAAgE;IAChE,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD;;;;OAIG;IACH,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACzD;;;;;OAKG;IACH,gBAAgB,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CACnF;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,CA+CpE"}
@@ -0,0 +1,51 @@
1
+ import { sanitizeForSupertonic } from './text.js';
2
+ import { TTS_SAMPLE_RATE, DEFAULT_VOICE_STREAM_PARAMS } from './config.js';
3
+ export function createQvacVoice(options) {
4
+ const sampleRate = options.ttsSampleRate ?? TTS_SAMPLE_RATE;
5
+ return {
6
+ async transcribeAudio(audioUri) {
7
+ const modelId = options.getWhisperModelId();
8
+ if (!modelId)
9
+ throw new Error('Whisper model not loaded');
10
+ // The SDK's native file reader wants a plain filesystem path, not a
11
+ // `file://` URI — the URI raises AUDIO_FILE_NOT_FOUND even when present.
12
+ const audioChunk = audioUri.replace('file://', '');
13
+ return await options.transcribe({ modelId, audioChunk });
14
+ },
15
+ async synthesizeSpeech(text) {
16
+ const modelId = options.getTtsModelId();
17
+ if (!modelId)
18
+ return null;
19
+ const trimmed = sanitizeForSupertonic(text);
20
+ if (!trimmed)
21
+ return null;
22
+ // Belt-and-suspenders: SUPERTONIC chokes on U+0060; sanitize already
23
+ // strips it, so refuse if any slipped through rather than crash the voice.
24
+ if (Array.from(trimmed).some((ch) => ch.charCodeAt(0) === 0x60))
25
+ return null;
26
+ const result = options.textToSpeech({
27
+ modelId,
28
+ text: trimmed,
29
+ inputType: 'text',
30
+ stream: false,
31
+ });
32
+ const pcm = await result.buffer;
33
+ return { pcm, sampleRate };
34
+ },
35
+ async openVoiceSession(paramsOverride = {}) {
36
+ if (!options.transcribeStream) {
37
+ throw new Error('transcribeStream not provided — pass it in QvacVoiceOptions for voice sessions');
38
+ }
39
+ const modelId = options.getWhisperModelId();
40
+ if (!modelId)
41
+ throw new Error('Whisper model not loaded');
42
+ const session = await options.transcribeStream({
43
+ modelId,
44
+ ...DEFAULT_VOICE_STREAM_PARAMS,
45
+ ...paramsOverride,
46
+ });
47
+ return session;
48
+ },
49
+ };
50
+ }
51
+ //# sourceMappingURL=voice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.js","sourceRoot":"","sources":["../../src/qvac/voice.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AA0D3E,MAAM,UAAU,eAAe,CAAC,OAAyB;IACvD,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,IAAI,eAAe,CAAC;IAE5D,OAAO;QACL,KAAK,CAAC,eAAe,CAAC,QAAgB;YACpC,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAC5C,IAAI,CAAC,OAAO;gBAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC;YAC1D,oEAAoE;YACpE,yEAAyE;YACzE,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;YACnD,OAAO,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,UAAU,EAAiC,CAAC,CAAC;QAC1F,CAAC;QAED,KAAK,CAAC,gBAAgB,CAAC,IAAY;YACjC,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;YACxC,IAAI,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC;YAE1B,MAAM,OAAO,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;YAC5C,IAAI,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC;YAC1B,qEAAqE;YACrE,2EAA2E;YAC3E,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC;YAE7E,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;gBAClC,OAAO;gBACP,IAAI,EAAE,OAAO;gBACb,SAAS,EAAE,MAAM;gBACjB,MAAM,EAAE,KAAK;aACmB,CAAC,CAAC;YACpC,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;YAChC,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;QAC7B,CAAC;QAED,KAAK,CAAC,gBAAgB,CAAC,iBAA0C,EAAE;YACjE,IAAI,CAAC,OAAO,CAAC,gBAAgB,EAAE,CAAC;gBAC9B,MAAM,IAAI,KAAK,CAAC,gFAAgF,CAAC,CAAC;YACpG,CAAC;YACD,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAC5C,IAAI,CAAC,OAAO;gBAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC;YAC1D,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,gBAAgB,CAAC;gBAC7C,OAAO;gBACP,GAAG,2BAA2B;gBAC9B,GAAG,cAAc;aACmB,CAAC,CAAC;YACxC,OAAO,OAAkC,CAAC;QAC5C,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaleidorg/mind",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first reasoning + function-calling engine for KaleidoSwap. QVAC-powered.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -34,6 +34,11 @@
34
34
  "types": "./dist/logger.d.ts",
35
35
  "import": "./dist/logger.js",
36
36
  "default": "./dist/logger.js"
37
+ },
38
+ "./qvac": {
39
+ "types": "./dist/qvac/index.d.ts",
40
+ "import": "./dist/qvac/index.js",
41
+ "default": "./dist/qvac/index.js"
37
42
  }
38
43
  },
39
44
  "files": [
@@ -51,7 +56,16 @@
51
56
  "lint": "eslint src",
52
57
  "bundle-skills": "node scripts/bundle-skills.mjs"
53
58
  },
59
+ "peerDependencies": {
60
+ "@qvac/sdk": ">=0.12.2"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@qvac/sdk": {
64
+ "optional": true
65
+ }
66
+ },
54
67
  "devDependencies": {
68
+ "@qvac/sdk": "^0.13.1",
55
69
  "@types/node": "^20.0.0",
56
70
  "vitest": "^1.6.0"
57
71
  }
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ shouldHandleUtterance,
4
+ runVoiceAssistant,
5
+ type VoiceTranscriptEvent,
6
+ } from './assistant.js';
7
+
8
+ const immediateSleep = async () => {};
9
+
10
+ function sessionOf(events: VoiceTranscriptEvent[]): AsyncIterable<VoiceTranscriptEvent> {
11
+ return {
12
+ async *[Symbol.asyncIterator]() {
13
+ for (const e of events) yield e;
14
+ },
15
+ };
16
+ }
17
+
18
+ describe('shouldHandleUtterance', () => {
19
+ it('drops utterances shorter than minChars', () => {
20
+ expect(shouldHandleUtterance('hi')).toBe(false);
21
+ expect(shouldHandleUtterance('go!', { minChars: 5 })).toBe(false);
22
+ });
23
+
24
+ it('drops known Whisper hallucinations regardless of trailing punctuation/case', () => {
25
+ expect(shouldHandleUtterance('you')).toBe(false);
26
+ expect(shouldHandleUtterance('Thanks.')).toBe(false);
27
+ expect(shouldHandleUtterance('Thank you')).toBe(false);
28
+ expect(shouldHandleUtterance('.')).toBe(false);
29
+ });
30
+
31
+ it('keeps a real request', () => {
32
+ expect(shouldHandleUtterance('what is my balance')).toBe(true);
33
+ });
34
+
35
+ it('honours a custom ignore list', () => {
36
+ expect(shouldHandleUtterance('computer', { ignoredUtterances: ['computer'] })).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe('runVoiceAssistant', () => {
41
+ it('handles only real utterances and ignores vad/short/hallucination events', async () => {
42
+ const respond = vi.fn(async (t: string) => `reply to ${t}`);
43
+ const speak = vi.fn(async () => {});
44
+ const session = sessionOf([
45
+ { type: 'vad', text: undefined },
46
+ { type: 'text', text: 'you' }, // hallucination → skipped
47
+ { type: 'text', text: 'what is my balance' }, // handled
48
+ { type: 'endOfTurn' },
49
+ ]);
50
+
51
+ await runVoiceAssistant(session, { respond, speak }, { sleep: immediateSleep });
52
+
53
+ expect(respond).toHaveBeenCalledTimes(1);
54
+ expect(respond).toHaveBeenCalledWith('what is my balance');
55
+ expect(speak).toHaveBeenCalledWith('reply to what is my balance');
56
+ });
57
+
58
+ it('gates the mic around playback and ends un-gated', async () => {
59
+ const gates: boolean[] = [];
60
+ const session = sessionOf([{ type: 'text', text: 'tell me a joke' }]);
61
+ await runVoiceAssistant(
62
+ session,
63
+ {
64
+ respond: async () => 'here is a joke',
65
+ speak: async () => {},
66
+ setMicGated: (g) => gates.push(g),
67
+ },
68
+ { sleep: immediateSleep },
69
+ );
70
+ // gated true before speaking, false after cooldown, false again on loop exit.
71
+ expect(gates).toEqual([true, false, false]);
72
+ });
73
+
74
+ it('emits listening → thinking → speaking → listening states', async () => {
75
+ const states: string[] = [];
76
+ const session = sessionOf([{ type: 'text', text: 'what time is it' }]);
77
+ await runVoiceAssistant(
78
+ session,
79
+ { respond: async () => 'noon', speak: async () => {}, onState: (s) => states.push(s) },
80
+ { sleep: immediateSleep },
81
+ );
82
+ expect(states).toEqual(['listening', 'thinking', 'speaking', 'listening']);
83
+ });
84
+
85
+ it('does not speak an empty reply', async () => {
86
+ const speak = vi.fn(async () => {});
87
+ const session = sessionOf([{ type: 'text', text: 'a vague mumble here' }]);
88
+ await runVoiceAssistant(
89
+ session,
90
+ { respond: async () => ' ', speak },
91
+ { sleep: immediateSleep },
92
+ );
93
+ expect(speak).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('survives a respond() error and keeps listening', async () => {
97
+ const speak = vi.fn(async () => {});
98
+ const session = sessionOf([
99
+ { type: 'text', text: 'first thing fails' },
100
+ { type: 'text', text: 'second thing works' },
101
+ ]);
102
+ const respond = vi
103
+ .fn<[string], Promise<string>>()
104
+ .mockRejectedValueOnce(new Error('boom'))
105
+ .mockResolvedValueOnce('ok');
106
+ await runVoiceAssistant(session, { respond, speak }, { sleep: immediateSleep });
107
+ expect(respond).toHaveBeenCalledTimes(2);
108
+ expect(speak).toHaveBeenCalledTimes(1);
109
+ expect(speak).toHaveBeenCalledWith('ok');
110
+ });
111
+
112
+ it('stops early when the signal is aborted', async () => {
113
+ const controller = new AbortController();
114
+ const respond = vi.fn(async (t: string) => {
115
+ controller.abort();
116
+ return `reply ${t}`;
117
+ });
118
+ const speak = vi.fn(async () => {});
119
+ const session = sessionOf([
120
+ { type: 'text', text: 'first utterance here' },
121
+ { type: 'text', text: 'second utterance here' },
122
+ ]);
123
+ await runVoiceAssistant(
124
+ session,
125
+ { respond, speak },
126
+ { sleep: immediateSleep, signal: controller.signal },
127
+ );
128
+ // Aborted during the first respond ⇒ never speaks, never handles the second.
129
+ expect(respond).toHaveBeenCalledTimes(1);
130
+ expect(speak).not.toHaveBeenCalled();
131
+ });
132
+ });