@kaleidorg/mind 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/qvac/assistant.d.ts +73 -0
- package/dist/qvac/assistant.d.ts.map +1 -0
- package/dist/qvac/assistant.js +97 -0
- package/dist/qvac/assistant.js.map +1 -0
- package/dist/qvac/config.d.ts +64 -0
- package/dist/qvac/config.d.ts.map +1 -0
- package/dist/qvac/config.js +71 -0
- package/dist/qvac/config.js.map +1 -0
- package/dist/qvac/delegate.d.ts +48 -0
- package/dist/qvac/delegate.d.ts.map +1 -0
- package/dist/qvac/delegate.js +51 -0
- package/dist/qvac/delegate.js.map +1 -0
- package/dist/qvac/index.d.ts +19 -0
- package/dist/qvac/index.d.ts.map +1 -0
- package/dist/qvac/index.js +19 -0
- package/dist/qvac/index.js.map +1 -0
- package/dist/qvac/parse.d.ts +44 -0
- package/dist/qvac/parse.d.ts.map +1 -0
- package/dist/qvac/parse.js +28 -0
- package/dist/qvac/parse.js.map +1 -0
- package/dist/qvac/provider.d.ts +49 -0
- package/dist/qvac/provider.d.ts.map +1 -0
- package/dist/qvac/provider.js +68 -0
- package/dist/qvac/provider.js.map +1 -0
- package/dist/qvac/stream.d.ts +37 -0
- package/dist/qvac/stream.d.ts.map +1 -0
- package/dist/qvac/stream.js +29 -0
- package/dist/qvac/stream.js.map +1 -0
- package/dist/qvac/text.d.ts +19 -0
- package/dist/qvac/text.d.ts.map +1 -0
- package/dist/qvac/text.js +56 -0
- package/dist/qvac/text.js.map +1 -0
- package/dist/qvac/voice.d.ts +69 -0
- package/dist/qvac/voice.d.ts.map +1 -0
- package/dist/qvac/voice.js +51 -0
- package/dist/qvac/voice.js.map +1 -0
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +15 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/qvac/assistant.test.ts +132 -0
- package/src/qvac/assistant.ts +146 -0
- package/src/qvac/config.test.ts +44 -0
- package/src/qvac/config.ts +76 -0
- package/src/qvac/delegate.test.ts +68 -0
- package/src/qvac/delegate.ts +71 -0
- package/src/qvac/index.ts +72 -0
- package/src/qvac/parse.test.ts +52 -0
- package/src/qvac/parse.ts +57 -0
- package/src/qvac/provider.test.ts +107 -0
- package/src/qvac/provider.ts +124 -0
- package/src/qvac/stream.test.ts +79 -0
- package/src/qvac/stream.ts +56 -0
- package/src/qvac/text.test.ts +70 -0
- package/src/qvac/text.ts +60 -0
- package/src/qvac/voice.test.ts +151 -0
- package/src/qvac/voice.ts +122 -0
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- package/src/skills/registry.ts +52 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { finalToTurn } from './parse.js';
|
|
3
|
+
|
|
4
|
+
describe('finalToTurn', () => {
|
|
5
|
+
it('uses contentText for visible text and strips reasoning', () => {
|
|
6
|
+
const out = finalToTurn({ contentText: '<think>x</think>Hello' });
|
|
7
|
+
expect(out.text).toBe('Hello');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('falls back to the streamed text when contentText is empty', () => {
|
|
11
|
+
const out = finalToTurn({ contentText: '' }, 'streamed answer');
|
|
12
|
+
expect(out.text).toBe('streamed answer');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('prefers raw.fullText for rawContent (history push-back)', () => {
|
|
16
|
+
const out = finalToTurn({ contentText: 'Hi', raw: { fullText: 'FRAMED<tool/>Hi' } });
|
|
17
|
+
expect(out.rawContent).toBe('FRAMED<tool/>Hi');
|
|
18
|
+
expect(out.text).toBe('Hi');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('falls back to the raw text for rawContent when no framed form', () => {
|
|
22
|
+
const out = finalToTurn({ contentText: 'Hi' });
|
|
23
|
+
expect(out.rawContent).toBe('Hi');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('maps tool calls and defaults missing arguments to {}', () => {
|
|
27
|
+
const out = finalToTurn({
|
|
28
|
+
contentText: '',
|
|
29
|
+
toolCalls: [{ id: 'a', name: 'get_balance' }, { name: 'send', arguments: { sats: 5000 } }],
|
|
30
|
+
});
|
|
31
|
+
expect(out.toolCalls).toEqual([
|
|
32
|
+
{ id: 'a', name: 'get_balance', arguments: {} },
|
|
33
|
+
{ id: undefined, name: 'send', arguments: { sats: 5000 } },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('flags truncation when the SDK stops on length', () => {
|
|
38
|
+
const out = finalToTurn({ contentText: 'partial', stopReason: 'length' });
|
|
39
|
+
expect(out.truncated).toBe(true);
|
|
40
|
+
expect(out.stopReason).toBe('length');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not flag truncation on a natural stop', () => {
|
|
44
|
+
const out = finalToTurn({ contentText: 'done' });
|
|
45
|
+
expect(out.truncated).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles an empty final without throwing', () => {
|
|
49
|
+
const out = finalToTurn({});
|
|
50
|
+
expect(out).toEqual({ text: '', rawContent: '', toolCalls: [], truncated: false, stopReason: undefined });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapping from a QVAC completion `final` frame to the shape the shared
|
|
3
|
+
* @kaleidorg/mind Engine consumes. Kept SDK-free (structural input type) so it
|
|
4
|
+
* is testable without loading a model, and so the same mapping runs on mobile,
|
|
5
|
+
* desktop, and the eval harness.
|
|
6
|
+
*/
|
|
7
|
+
import { cleanAssistantVisibleText } from './text.js';
|
|
8
|
+
|
|
9
|
+
/** Structural subset of a QVAC `completion().final` we depend on. */
|
|
10
|
+
export interface QvacFinalLike {
|
|
11
|
+
/** Visible assistant text (excludes `<think>` reasoning). */
|
|
12
|
+
contentText?: string;
|
|
13
|
+
/** Raw assistant frame, incl. tool-call framing, for history push-back. */
|
|
14
|
+
raw?: { fullText?: string };
|
|
15
|
+
/** Tool calls the model requested this turn (empty ⇒ final answer). */
|
|
16
|
+
toolCalls?: Array<{ id?: string; name: string; arguments?: Record<string, unknown> }>;
|
|
17
|
+
/**
|
|
18
|
+
* Why generation stopped. QVAC 0.13 emits `"length"` when the token budget is
|
|
19
|
+
* exhausted, `"cancelled"` on abort, `undefined` on a natural stop. We surface
|
|
20
|
+
* it so the funnel can tell a truncated tool-call from a complete one.
|
|
21
|
+
*/
|
|
22
|
+
stopReason?: 'length' | 'cancelled' | string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedTurn {
|
|
26
|
+
/** Cleaned assistant content for display. */
|
|
27
|
+
text: string;
|
|
28
|
+
/** Raw assistant frame to push back into history for the next turn. */
|
|
29
|
+
rawContent: string;
|
|
30
|
+
/** Tool calls the model requested (arguments defaulted to `{}`). */
|
|
31
|
+
toolCalls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }>;
|
|
32
|
+
/** True when generation was cut off by the token budget (incomplete output). */
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
/** Raw stop reason from the SDK, when provided. */
|
|
35
|
+
stopReason?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map a completion `final` (plus the streamed fallback text) into a ParsedTurn.
|
|
40
|
+
* `rawContent` prefers the SDK's framed `raw.fullText` so the Engine can anchor
|
|
41
|
+
* the next turn; falls back to the visible text when a provider has no raw form.
|
|
42
|
+
*/
|
|
43
|
+
export function finalToTurn(final: QvacFinalLike, streamed = ''): ParsedTurn {
|
|
44
|
+
const rawText = final.contentText || streamed;
|
|
45
|
+
const text = cleanAssistantVisibleText(rawText);
|
|
46
|
+
return {
|
|
47
|
+
text,
|
|
48
|
+
rawContent: final.raw?.fullText ?? rawText,
|
|
49
|
+
toolCalls: (final.toolCalls ?? []).map((c) => ({
|
|
50
|
+
id: c.id,
|
|
51
|
+
name: c.name,
|
|
52
|
+
arguments: c.arguments ?? {},
|
|
53
|
+
})),
|
|
54
|
+
truncated: final.stopReason === 'length',
|
|
55
|
+
stopReason: final.stopReason,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createQvacProvider } from './provider.js';
|
|
3
|
+
|
|
4
|
+
/** A fake `completion` that records its params and replays scripted events. */
|
|
5
|
+
function fakeCompletion(
|
|
6
|
+
final: Record<string, unknown>,
|
|
7
|
+
events: Array<{ type: string; text?: string }> = [],
|
|
8
|
+
) {
|
|
9
|
+
const calls: any[] = [];
|
|
10
|
+
const fn = (params: any) => {
|
|
11
|
+
calls.push(params);
|
|
12
|
+
return {
|
|
13
|
+
requestId: 'req-1',
|
|
14
|
+
events: (async function* () {
|
|
15
|
+
for (const e of events) yield e;
|
|
16
|
+
})(),
|
|
17
|
+
final: Promise.resolve(final),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
return { fn, calls };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const noopCancel = (async () => {}) as any;
|
|
24
|
+
|
|
25
|
+
describe('createQvacProvider.runTurn', () => {
|
|
26
|
+
it('throws when no model is loaded', async () => {
|
|
27
|
+
const p = createQvacProvider({
|
|
28
|
+
completion: (() => { throw new Error('should not be called'); }) as any,
|
|
29
|
+
cancel: noopCancel,
|
|
30
|
+
getModelId: () => null,
|
|
31
|
+
});
|
|
32
|
+
await expect(p.runTurn({ messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
33
|
+
.rejects.toThrow(/not loaded/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('prepends the system message and sets generationParams + captureThinking', async () => {
|
|
37
|
+
const { fn, calls } = fakeCompletion({ contentText: 'Hello', toolCalls: [], raw: { fullText: 'Hello' } });
|
|
38
|
+
const p = createQvacProvider({
|
|
39
|
+
completion: fn as any,
|
|
40
|
+
cancel: noopCancel,
|
|
41
|
+
getModelId: () => 'm1',
|
|
42
|
+
defaultTemperature: 0.5,
|
|
43
|
+
defaultMaxTokens: 256,
|
|
44
|
+
});
|
|
45
|
+
const out = await p.runTurn({ system: 'You are X', messages: [{ role: 'user', content: 'hi' }], tools: [] });
|
|
46
|
+
expect(out.text).toBe('Hello');
|
|
47
|
+
|
|
48
|
+
const params = calls[0];
|
|
49
|
+
expect(params.modelId).toBe('m1');
|
|
50
|
+
expect(params.history).toEqual([
|
|
51
|
+
{ role: 'system', content: 'You are X' },
|
|
52
|
+
{ role: 'user', content: 'hi' },
|
|
53
|
+
]);
|
|
54
|
+
expect(params.stream).toBe(true);
|
|
55
|
+
expect(params.captureThinking).toBe(true);
|
|
56
|
+
expect(params.generationParams).toEqual({ temp: 0.5, predict: 256 });
|
|
57
|
+
expect(params.tools).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps tools by schema and honours per-call temperature/maxTokens', async () => {
|
|
61
|
+
const { fn, calls } = fakeCompletion({
|
|
62
|
+
contentText: '',
|
|
63
|
+
toolCalls: [{ id: 'a', name: 'get_balance', arguments: {} }],
|
|
64
|
+
raw: { fullText: '' },
|
|
65
|
+
});
|
|
66
|
+
const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
|
|
67
|
+
const out = await p.runTurn({
|
|
68
|
+
messages: [{ role: 'user', content: 'balance?' }],
|
|
69
|
+
tools: [{ name: 'get_balance', description: 'balance', parameters: { shape: true } }],
|
|
70
|
+
temperature: 0.9,
|
|
71
|
+
maxTokens: 99,
|
|
72
|
+
} as any);
|
|
73
|
+
|
|
74
|
+
expect(out.toolCalls).toEqual([{ id: 'a', name: 'get_balance', arguments: {} }]);
|
|
75
|
+
const params = calls[0];
|
|
76
|
+
expect(params.tools).toEqual([{ name: 'get_balance', description: 'balance', parameters: { shape: true } }]);
|
|
77
|
+
expect(params.generationParams).toEqual({ temp: 0.9, predict: 99 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('omits generationParams when no temperature/maxTokens is set (keeps SDK defaults)', async () => {
|
|
81
|
+
const { fn, calls } = fakeCompletion({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } });
|
|
82
|
+
const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
|
|
83
|
+
await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [] });
|
|
84
|
+
expect(calls[0].generationParams).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('streams visible content tokens to onToken', async () => {
|
|
88
|
+
const { fn } = fakeCompletion(
|
|
89
|
+
{ contentText: 'Hi there', toolCalls: [], raw: { fullText: 'Hi there' } },
|
|
90
|
+
[{ type: 'contentDelta', text: 'Hi ' }, { type: 'contentDelta', text: 'there' }],
|
|
91
|
+
);
|
|
92
|
+
const tokens: string[] = [];
|
|
93
|
+
const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
|
|
94
|
+
await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [], onToken: (t) => tokens.push(t) });
|
|
95
|
+
expect(tokens).toEqual(['Hi ', 'there']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('createQvacProvider.cancel', () => {
|
|
100
|
+
it('forwards the requestId to the SDK cancel', async () => {
|
|
101
|
+
const cancel = vi.fn(async () => {});
|
|
102
|
+
const { fn } = fakeCompletion({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } });
|
|
103
|
+
const p = createQvacProvider({ completion: fn as any, cancel: cancel as any, getModelId: () => 'm1' });
|
|
104
|
+
await p.cancel!('req-9');
|
|
105
|
+
expect(cancel).toHaveBeenCalledWith({ requestId: 'req-9' });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createQvacProvider — turns `@qvac/sdk` `completion()` into the shared
|
|
3
|
+
* `@kaleidorg/mind` `LLMProvider` the Engine/Funnel consumes. This is the one
|
|
4
|
+
* place the SDK is called for inference; every host (rate, desktop provider,
|
|
5
|
+
* cli) uses it instead of hand-rolling its own completion wrapper.
|
|
6
|
+
*
|
|
7
|
+
* The SDK functions are *injected*, not imported, so this package carries no
|
|
8
|
+
* runtime dependency on `@qvac/sdk` (the import below is type-only and erased).
|
|
9
|
+
* Hosts pass their own `completion`/`cancel` — rate the static RN import, the
|
|
10
|
+
* desktop sidecar its lazily-loaded SDK facade — which also makes this provider
|
|
11
|
+
* unit-testable with a fake completion.
|
|
12
|
+
*
|
|
13
|
+
* The host owns model lifecycle (load/unload, local-vs-delegated) and passes
|
|
14
|
+
* `getModelId()` so a turn always runs against the currently-loaded model.
|
|
15
|
+
* Tools are forwarded by schema only; the Engine executes them via its
|
|
16
|
+
* ToolSources, so signing/spending stays on the host even when inference is
|
|
17
|
+
* delegated to a desktop peer.
|
|
18
|
+
*/
|
|
19
|
+
import type * as QvacSdk from '@qvac/sdk';
|
|
20
|
+
import type { LLMProvider, TurnInput, TurnOutput } from '../providers/types.js';
|
|
21
|
+
import { consumeRun } from './stream.js';
|
|
22
|
+
|
|
23
|
+
type CompletionFn = typeof QvacSdk.completion;
|
|
24
|
+
type CancelFn = typeof QvacSdk.cancel;
|
|
25
|
+
|
|
26
|
+
export interface QvacProviderOptions {
|
|
27
|
+
/** The SDK's `completion` (injected — see module docs). */
|
|
28
|
+
completion: CompletionFn;
|
|
29
|
+
/** The SDK's `cancel` (injected). */
|
|
30
|
+
cancel: CancelFn;
|
|
31
|
+
/** Resolve the loaded model id for this turn (null ⇒ not loaded → throws). */
|
|
32
|
+
getModelId: () => string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Default sampling temperature. Omit to leave it to the SDK/model default —
|
|
35
|
+
* `generationParams` is only sent when a temperature or max-tokens is set, so
|
|
36
|
+
* a host that passes neither preserves the SDK's own defaults.
|
|
37
|
+
*/
|
|
38
|
+
defaultTemperature?: number;
|
|
39
|
+
/** Default max output tokens — caps a turn so it can't ramble. Omit for uncapped. */
|
|
40
|
+
defaultMaxTokens?: number;
|
|
41
|
+
/** Stream the model's `<think>` reasoning, when a host wants to surface it. */
|
|
42
|
+
onThinking?: (token: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** TurnInput plus the per-call knobs the funnel/voice paths pass through. */
|
|
46
|
+
export interface QvacTurnInput extends TurnInput {
|
|
47
|
+
temperature?: number;
|
|
48
|
+
maxTokens?: number;
|
|
49
|
+
onThinking?: (token: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
|
|
53
|
+
return {
|
|
54
|
+
name: 'qvac',
|
|
55
|
+
|
|
56
|
+
async runTurn(input: QvacTurnInput): Promise<TurnOutput> {
|
|
57
|
+
const modelId = options.getModelId();
|
|
58
|
+
if (!modelId) throw new Error('QVAC model not loaded');
|
|
59
|
+
|
|
60
|
+
const history = input.system
|
|
61
|
+
? [{ role: 'system', content: input.system }, ...input.messages]
|
|
62
|
+
: input.messages;
|
|
63
|
+
|
|
64
|
+
// Tools are forwarded by schema only (name/description/parameters). We
|
|
65
|
+
// carry `parameters` through verbatim (Zod for in-process tools, JSON
|
|
66
|
+
// Schema for MCP) — the model only needs the shape to pick a call; the
|
|
67
|
+
// Engine validates + executes.
|
|
68
|
+
const tools = input.tools.length
|
|
69
|
+
? input.tools.map((t) => ({
|
|
70
|
+
name: t.name,
|
|
71
|
+
description: t.description,
|
|
72
|
+
parameters: t.parameters,
|
|
73
|
+
}))
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
76
|
+
// QVAC 0.13 nests sampling under `generationParams`; top-level
|
|
77
|
+
// `temperature`/`max_tokens` (as older rate code passed) are dropped by
|
|
78
|
+
// validation, so the cap silently no-op'd. Build it here, and only send it
|
|
79
|
+
// when a value is set so a host that passes neither keeps SDK defaults.
|
|
80
|
+
const temp = input.temperature ?? options.defaultTemperature;
|
|
81
|
+
const predict = input.maxTokens ?? options.defaultMaxTokens;
|
|
82
|
+
const generationParams =
|
|
83
|
+
temp !== undefined || predict !== undefined
|
|
84
|
+
? {
|
|
85
|
+
...(temp !== undefined ? { temp } : {}),
|
|
86
|
+
...(predict !== undefined ? { predict } : {}),
|
|
87
|
+
}
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
const run = options.completion({
|
|
91
|
+
modelId,
|
|
92
|
+
history,
|
|
93
|
+
stream: true,
|
|
94
|
+
// Split `<think>` into separate thinkingDelta events so reasoning never
|
|
95
|
+
// pollutes the visible answer.
|
|
96
|
+
captureThinking: true,
|
|
97
|
+
...(generationParams ? { generationParams } : {}),
|
|
98
|
+
...(tools ? { tools } : {}),
|
|
99
|
+
} as unknown as Parameters<CompletionFn>[0]);
|
|
100
|
+
|
|
101
|
+
const result = await consumeRun(run, {
|
|
102
|
+
onToken: input.onToken,
|
|
103
|
+
onThinking: input.onThinking ?? options.onThinking,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
text: result.text,
|
|
108
|
+
rawContent: result.rawContent,
|
|
109
|
+
toolCalls: result.toolCalls,
|
|
110
|
+
requestId: result.requestId,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async cancel(requestId: string): Promise<void> {
|
|
115
|
+
// The cancel only lands once the server has begun the request; a same-tick
|
|
116
|
+
// cancel may race the begin and is logged as a no-match by the SDK.
|
|
117
|
+
try {
|
|
118
|
+
await options.cancel({ requestId });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn('[qvac] cancel failed:', err);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { consumeRun, type CompletionEventLike, type CompletionRunLike } from './stream.js';
|
|
3
|
+
import type { QvacFinalLike } from './parse.js';
|
|
4
|
+
|
|
5
|
+
function fakeRun(
|
|
6
|
+
events: CompletionEventLike[],
|
|
7
|
+
final: QvacFinalLike,
|
|
8
|
+
requestId = 'req-1',
|
|
9
|
+
): CompletionRunLike {
|
|
10
|
+
return {
|
|
11
|
+
requestId,
|
|
12
|
+
events: (async function* () {
|
|
13
|
+
for (const e of events) yield e;
|
|
14
|
+
})(),
|
|
15
|
+
final: Promise.resolve(final),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('consumeRun', () => {
|
|
20
|
+
it('forwards visible content tokens and accumulates the streamed fallback', async () => {
|
|
21
|
+
const tokens: string[] = [];
|
|
22
|
+
const run = fakeRun(
|
|
23
|
+
[
|
|
24
|
+
{ type: 'contentDelta', text: 'Hel' },
|
|
25
|
+
{ type: 'contentDelta', text: 'lo' },
|
|
26
|
+
],
|
|
27
|
+
{ contentText: '', toolCalls: [], raw: { fullText: '' } },
|
|
28
|
+
);
|
|
29
|
+
const out = await consumeRun(run, { onToken: (t) => tokens.push(t) });
|
|
30
|
+
expect(tokens).toEqual(['Hel', 'lo']);
|
|
31
|
+
// contentText empty ⇒ falls back to the streamed accumulation.
|
|
32
|
+
expect(out.text).toBe('Hello');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('routes thinkingDelta to onThinking, not onToken', async () => {
|
|
36
|
+
const visible: string[] = [];
|
|
37
|
+
const thinking: string[] = [];
|
|
38
|
+
const run = fakeRun(
|
|
39
|
+
[
|
|
40
|
+
{ type: 'thinkingDelta', text: 'plan…' },
|
|
41
|
+
{ type: 'contentDelta', text: 'Answer' },
|
|
42
|
+
],
|
|
43
|
+
{ contentText: 'Answer', toolCalls: [], raw: { fullText: 'Answer' } },
|
|
44
|
+
);
|
|
45
|
+
await consumeRun(run, {
|
|
46
|
+
onToken: (t) => visible.push(t),
|
|
47
|
+
onThinking: (t) => thinking.push(t),
|
|
48
|
+
});
|
|
49
|
+
expect(visible).toEqual(['Answer']);
|
|
50
|
+
expect(thinking).toEqual(['plan…']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns parsed tool calls + requestId and flags truncation from stopReason', async () => {
|
|
54
|
+
const run = fakeRun(
|
|
55
|
+
[],
|
|
56
|
+
{
|
|
57
|
+
contentText: 'partial',
|
|
58
|
+
toolCalls: [{ id: 't1', name: 'get_balance', arguments: {} }],
|
|
59
|
+
raw: { fullText: 'partial' },
|
|
60
|
+
stopReason: 'length',
|
|
61
|
+
},
|
|
62
|
+
'req-xyz',
|
|
63
|
+
);
|
|
64
|
+
const out = await consumeRun(run);
|
|
65
|
+
expect(out.requestId).toBe('req-xyz');
|
|
66
|
+
expect(out.toolCalls).toEqual([{ id: 't1', name: 'get_balance', arguments: {} }]);
|
|
67
|
+
expect(out.truncated).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('ignores delta events with no text', async () => {
|
|
71
|
+
const tokens: string[] = [];
|
|
72
|
+
const run = fakeRun(
|
|
73
|
+
[{ type: 'contentDelta' }, { type: 'toolCall' }, { type: 'contentDelta', text: 'hi' }],
|
|
74
|
+
{ contentText: 'hi', toolCalls: [], raw: { fullText: 'hi' } },
|
|
75
|
+
);
|
|
76
|
+
await consumeRun(run, { onToken: (t) => tokens.push(t) });
|
|
77
|
+
expect(tokens).toEqual(['hi']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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, type ParsedTurn, type QvacFinalLike } from './parse.js';
|
|
10
|
+
|
|
11
|
+
/** Minimal shape of a QVAC completion event we react to. */
|
|
12
|
+
export interface CompletionEventLike {
|
|
13
|
+
type: string;
|
|
14
|
+
/** Present on `contentDelta` / `thinkingDelta` / `rawDelta`. */
|
|
15
|
+
text?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Structural subset of `completion()`'s return we depend on. */
|
|
19
|
+
export interface CompletionRunLike {
|
|
20
|
+
requestId: string;
|
|
21
|
+
events: AsyncIterable<CompletionEventLike>;
|
|
22
|
+
final: Promise<QvacFinalLike>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StreamHandlers {
|
|
26
|
+
/** Visible assistant tokens (excludes `<think>` reasoning). */
|
|
27
|
+
onToken?: (token: string) => void;
|
|
28
|
+
/** The model's `<think>` reasoning, streamed separately. */
|
|
29
|
+
onThinking?: (token: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConsumedTurn extends ParsedTurn {
|
|
33
|
+
requestId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stream a run to completion. `contentDelta` → onToken (and the streamed
|
|
38
|
+
* fallback text), `thinkingDelta` → onThinking. Returns the parsed turn plus the
|
|
39
|
+
* run's `requestId` (for cancellation bookkeeping by the caller).
|
|
40
|
+
*/
|
|
41
|
+
export async function consumeRun(
|
|
42
|
+
run: CompletionRunLike,
|
|
43
|
+
handlers: StreamHandlers = {},
|
|
44
|
+
): Promise<ConsumedTurn> {
|
|
45
|
+
let streamed = '';
|
|
46
|
+
for await (const event of run.events) {
|
|
47
|
+
if (event.type === 'contentDelta' && typeof event.text === 'string') {
|
|
48
|
+
streamed += event.text;
|
|
49
|
+
handlers.onToken?.(event.text);
|
|
50
|
+
} else if (event.type === 'thinkingDelta' && typeof event.text === 'string') {
|
|
51
|
+
handlers.onThinking?.(event.text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const final = await run.final;
|
|
55
|
+
return { ...finalToTurn(final, streamed), requestId: run.requestId };
|
|
56
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cleanAssistantVisibleText, sanitizeForSupertonic } from './text.js';
|
|
3
|
+
|
|
4
|
+
describe('cleanAssistantVisibleText', () => {
|
|
5
|
+
it('strips a closed <think> block', () => {
|
|
6
|
+
expect(cleanAssistantVisibleText('<think>plan the answer</think>Your balance is 5k sats.'))
|
|
7
|
+
.toBe('Your balance is 5k sats.');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('strips an unclosed <think> tail', () => {
|
|
11
|
+
expect(cleanAssistantVisibleText('Done.<think>still reasoning'))
|
|
12
|
+
.toBe('Done.');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('drops a leading tool-call prefix and keeps the sentence after it', () => {
|
|
16
|
+
// The heuristic strips up to `"arguments":` then one `{`. Cleanly recovers
|
|
17
|
+
// the tail when the model emits the framing without real JSON args.
|
|
18
|
+
const raw = '{"name":"get_balance","arguments": You have 5,000 sats.';
|
|
19
|
+
expect(cleanAssistantVisibleText(raw)).toBe('You have 5,000 sats.');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('is lossy when real JSON args follow (known heuristic limit, stray braces remain)', () => {
|
|
23
|
+
// Documents current behaviour verbatim from rate; a candidate for a future
|
|
24
|
+
// balanced-brace fix once it can be re-verified on device.
|
|
25
|
+
const raw = '{"name":"get_balance","arguments":{}} You have 5,000 sats.';
|
|
26
|
+
expect(cleanAssistantVisibleText(raw)).toBe('}} You have 5,000 sats.');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('collapses whitespace and trims', () => {
|
|
30
|
+
expect(cleanAssistantVisibleText(' hello world ')).toBe('hello world');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('leaves plain text untouched', () => {
|
|
34
|
+
expect(cleanAssistantVisibleText('Sent 3 EUR to bob.')).toBe('Sent 3 EUR to bob.');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('sanitizeForSupertonic', () => {
|
|
39
|
+
it('redacts a bolt11 lightning invoice', () => {
|
|
40
|
+
const out = sanitizeForSupertonic('Pay lnbc1' + 'q'.repeat(60) + ' now');
|
|
41
|
+
expect(out).toContain('Lightning invoice');
|
|
42
|
+
expect(out).not.toMatch(/lnbc1q/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('redacts an lnurl string', () => {
|
|
46
|
+
const out = sanitizeForSupertonic('Use lnurl1' + 'a'.repeat(50));
|
|
47
|
+
expect(out).toContain('Lightning payment link');
|
|
48
|
+
expect(out).not.toMatch(/lnurl1a/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('strips fenced code blocks and inline backticks', () => {
|
|
52
|
+
const out = sanitizeForSupertonic('Run ```rm -rf``` or `ls` here');
|
|
53
|
+
expect(out).not.toContain('`');
|
|
54
|
+
expect(out).toContain('ls');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('removes the backtick character (U+0060) entirely', () => {
|
|
58
|
+
const out = sanitizeForSupertonic('a`b`c');
|
|
59
|
+
expect(Array.from(out).some((ch) => ch.charCodeAt(0) === 0x60)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('normalizes smart quotes to ASCII', () => {
|
|
63
|
+
const out = sanitizeForSupertonic('“hello” ‘world’');
|
|
64
|
+
expect(out).toBe('"hello" \'world\'');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('drops non-ASCII and collapses whitespace', () => {
|
|
68
|
+
expect(sanitizeForSupertonic('café ✨ ok')).toBe('caf ok');
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/qvac/text.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text helpers for QVAC output. No SDK, no platform — safe to run and test
|
|
3
|
+
* anywhere. Lifted verbatim from rate's QVACService so every host shares one
|
|
4
|
+
* implementation instead of drifting copies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Clean a raw assistant completion into user-visible text:
|
|
9
|
+
* - drop `<think>…</think>` reasoning (small models leak it into content),
|
|
10
|
+
* - drop a leading `{"name":…,"arguments":…}` tool-call object some tiny models
|
|
11
|
+
* emit as plain text, keeping any natural-language sentence that follows.
|
|
12
|
+
*/
|
|
13
|
+
export function cleanAssistantVisibleText(text: string): string {
|
|
14
|
+
let cleaned = text
|
|
15
|
+
// Qwen-style reasoning sometimes arrives in contentText. Never show/speak it.
|
|
16
|
+
.replace(/<think\b[\s\S]*?<\/think>/gi, ' ')
|
|
17
|
+
.replace(/<think\b[\s\S]*$/gi, ' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
|
|
21
|
+
// Some small local models emit a tool-call object as plain text. Drop the
|
|
22
|
+
// leading fragment and keep any natural-language sentence that follows.
|
|
23
|
+
const toolPrefix = cleaned.match(/^\s*\{?\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*/i);
|
|
24
|
+
if (toolPrefix) {
|
|
25
|
+
cleaned = cleaned.slice(toolPrefix[0].length).replace(/^\s*\{?\s*/, '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return cleaned
|
|
29
|
+
.replace(/\s+/g, ' ')
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Make text safe for the SUPERTONIC TTS model: redact payment strings (so they
|
|
35
|
+
* are never read aloud), strip markdown/code, normalize smart punctuation, and
|
|
36
|
+
* drop any non-ASCII or backtick (U+0060) the model can't synthesize.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeForSupertonic(text: string): string {
|
|
39
|
+
const normalized = text
|
|
40
|
+
.replace(/\b(?:lightning:)?ln(?:bc|tb|bcrt)[a-z0-9]{40,}\b/gi, 'Lightning invoice')
|
|
41
|
+
.replace(/\blnurl[0-9a-z]{40,}\b/gi, 'Lightning payment link')
|
|
42
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
43
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
44
|
+
.replace(/[`´ˋ′*_~#<>|[\]{}]/g, ' ')
|
|
45
|
+
.replace(/[“”]/g, '"')
|
|
46
|
+
.replace(/[‘’]/g, "'")
|
|
47
|
+
.replace(/[•·]/g, '. ')
|
|
48
|
+
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, ' ')
|
|
49
|
+
.replace(/\s+/g, ' ');
|
|
50
|
+
|
|
51
|
+
return Array.from(normalized)
|
|
52
|
+
.filter((ch) => {
|
|
53
|
+
const code = ch.charCodeAt(0);
|
|
54
|
+
return (code === 0x09 || code === 0x0A || code === 0x0D || (code >= 0x20 && code <= 0x7E)) &&
|
|
55
|
+
code !== 0x60;
|
|
56
|
+
})
|
|
57
|
+
.join('')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
}
|