@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.
- 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/package.json +15 -1
- 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
|
@@ -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
|
+
"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
|
+
});
|