@kaleidorg/mind 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities.d.ts +4 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +7 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/funnel.d.ts +6 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +26 -6
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +72 -0
- package/dist/kaleidoswap/contract.d.ts.map +1 -0
- package/dist/kaleidoswap/contract.js +125 -0
- package/dist/kaleidoswap/contract.js.map +1 -0
- package/dist/knowledge/btc-map.d.ts +87 -0
- package/dist/knowledge/btc-map.d.ts.map +1 -0
- package/dist/knowledge/btc-map.js +365 -0
- package/dist/knowledge/btc-map.js.map +1 -0
- package/dist/lsps1/contract.d.ts +55 -0
- package/dist/lsps1/contract.d.ts.map +1 -0
- package/dist/lsps1/contract.js +91 -0
- package/dist/lsps1/contract.js.map +1 -0
- package/dist/memory/store.d.ts +7 -1
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +43 -3
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.d.ts +12 -0
- package/dist/memory/types.d.ts.map +1 -1
- package/dist/qvac/assistant.d.ts +73 -0
- package/dist/qvac/assistant.d.ts.map +1 -0
- package/dist/qvac/assistant.js +97 -0
- package/dist/qvac/assistant.js.map +1 -0
- package/dist/qvac/config.d.ts +64 -0
- package/dist/qvac/config.d.ts.map +1 -0
- package/dist/qvac/config.js +71 -0
- package/dist/qvac/config.js.map +1 -0
- package/dist/qvac/delegate.d.ts +48 -0
- package/dist/qvac/delegate.d.ts.map +1 -0
- package/dist/qvac/delegate.js +51 -0
- package/dist/qvac/delegate.js.map +1 -0
- package/dist/qvac/index.d.ts +19 -0
- package/dist/qvac/index.d.ts.map +1 -0
- package/dist/qvac/index.js +19 -0
- package/dist/qvac/index.js.map +1 -0
- package/dist/qvac/parse.d.ts +44 -0
- package/dist/qvac/parse.d.ts.map +1 -0
- package/dist/qvac/parse.js +28 -0
- package/dist/qvac/parse.js.map +1 -0
- package/dist/qvac/provider.d.ts +49 -0
- package/dist/qvac/provider.d.ts.map +1 -0
- package/dist/qvac/provider.js +68 -0
- package/dist/qvac/provider.js.map +1 -0
- package/dist/qvac/stream.d.ts +37 -0
- package/dist/qvac/stream.d.ts.map +1 -0
- package/dist/qvac/stream.js +29 -0
- package/dist/qvac/stream.js.map +1 -0
- package/dist/qvac/text.d.ts +19 -0
- package/dist/qvac/text.d.ts.map +1 -0
- package/dist/qvac/text.js +56 -0
- package/dist/qvac/text.js.map +1 -0
- package/dist/qvac/voice.d.ts +69 -0
- package/dist/qvac/voice.d.ts.map +1 -0
- package/dist/qvac/voice.js +51 -0
- package/dist/qvac/voice.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.js +111 -0
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +13 -1
- package/dist/recipe/runner.js.map +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +20 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/wallet/confirm.d.ts +12 -0
- package/dist/wallet/confirm.d.ts.map +1 -0
- package/dist/wallet/confirm.js +67 -0
- package/dist/wallet/confirm.js.map +1 -0
- package/package.json +16 -1
- package/skills/README.md +6 -1
- package/skills/kaleido-lsps/SKILL.md +56 -0
- package/skills/kaleido-trading/SKILL.md +85 -18
- package/skills/merchant-finder/SKILL.md +87 -0
- package/skills/paid-data/SKILL.md +12 -0
- package/skills/wallet-assistant/SKILL.md +38 -0
- package/src/capabilities.ts +12 -0
- package/src/context/context.test.ts +6 -2
- package/src/engine.ts +6 -0
- package/src/funnel.ts +32 -7
- package/src/index.ts +43 -0
- package/src/kaleidoswap/contract.test.ts +147 -0
- package/src/kaleidoswap/contract.ts +212 -0
- package/src/knowledge/btc-map.test.ts +188 -0
- package/src/knowledge/btc-map.ts +446 -0
- package/src/lsps1/contract.test.ts +81 -0
- package/src/lsps1/contract.ts +132 -0
- package/src/memory/memory.test.ts +55 -0
- package/src/memory/store.ts +49 -4
- package/src/memory/types.ts +13 -0
- package/src/qvac/assistant.test.ts +132 -0
- package/src/qvac/assistant.ts +146 -0
- package/src/qvac/config.test.ts +44 -0
- package/src/qvac/config.ts +76 -0
- package/src/qvac/delegate.test.ts +68 -0
- package/src/qvac/delegate.ts +71 -0
- package/src/qvac/index.ts +72 -0
- package/src/qvac/parse.test.ts +52 -0
- package/src/qvac/parse.ts +57 -0
- package/src/qvac/provider.test.ts +107 -0
- package/src/qvac/provider.ts +124 -0
- package/src/qvac/stream.test.ts +79 -0
- package/src/qvac/stream.ts +56 -0
- package/src/qvac/text.test.ts +70 -0
- package/src/qvac/text.ts +60 -0
- package/src/qvac/voice.test.ts +151 -0
- package/src/qvac/voice.ts +122 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
- package/src/recipe/kaleidoswap-atomic.ts +117 -0
- package/src/recipe/runner.ts +13 -1
- package/src/skills/registry.ts +21 -2
- package/src/skills/skills.test.ts +42 -0
- package/src/wallet/confirm.test.ts +57 -0
- package/src/wallet/confirm.ts +74 -0
- package/skills/kaleido-wallet/SKILL.md +0 -28
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation helpers — the provider firewall (who may connect) and the
|
|
3
|
+
* consumer-side delegate config. Pure data builders (no `@qvac/sdk` import) so
|
|
4
|
+
* they stay shared + testable; the host passes the result to
|
|
5
|
+
* `startQVACProvider({ firewall })` / `loadModel({ delegate })`.
|
|
6
|
+
*
|
|
7
|
+
* Security note: a QVAC provider is reachable by anyone who learns its
|
|
8
|
+
* Hyperswarm public key. Advertising with no firewall means any such peer can
|
|
9
|
+
* run inference on your machine. Use {@link allowListFirewall} so a desktop
|
|
10
|
+
* provider serves ONLY its paired phone(s).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Firewall for `startQVACProvider` — restrict who may delegate to this provider. */
|
|
14
|
+
export interface ProviderFirewall {
|
|
15
|
+
mode: 'allow' | 'deny';
|
|
16
|
+
publicKeys: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeKeys(keys: Iterable<string>): string[] {
|
|
20
|
+
return [...new Set([...keys].map((k) => k.trim()).filter(Boolean))];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Allow ONLY these consumer public keys to delegate (zero-trust). Pass the
|
|
25
|
+
* paired phone(s)' public keys so no one else can use the desktop brain even if
|
|
26
|
+
* they learn its public key.
|
|
27
|
+
*/
|
|
28
|
+
export function allowListFirewall(consumerPublicKeys: Iterable<string>): ProviderFirewall {
|
|
29
|
+
return { mode: 'allow', publicKeys: normalizeKeys(consumerPublicKeys) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Deny these consumer public keys; everyone else may connect. */
|
|
33
|
+
export function denyListFirewall(consumerPublicKeys: Iterable<string>): ProviderFirewall {
|
|
34
|
+
return { mode: 'deny', publicKeys: normalizeKeys(consumerPublicKeys) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse a comma/space/newline-separated key list (e.g. from an env var or a
|
|
39
|
+
* pairing store) into an allow-list firewall, or `undefined` when none are
|
|
40
|
+
* configured — the caller then advertises openly and should warn.
|
|
41
|
+
*/
|
|
42
|
+
export function firewallFromKeyList(raw: string | null | undefined): ProviderFirewall | undefined {
|
|
43
|
+
if (!raw) return undefined;
|
|
44
|
+
const keys = raw.split(/[\s,]+/).map((k) => k.trim()).filter(Boolean);
|
|
45
|
+
return keys.length ? allowListFirewall(keys) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Consumer-side config for `loadModel({ delegate })`. */
|
|
49
|
+
export interface DelegateConfig {
|
|
50
|
+
providerPublicKey: string;
|
|
51
|
+
fallbackToLocal: boolean;
|
|
52
|
+
timeout?: number;
|
|
53
|
+
forceNewConnection?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the `delegate` config for a delegated `loadModel`. `fallbackToLocal`
|
|
58
|
+
* defaults to false (the host owns recovery), matching rate's existing
|
|
59
|
+
* LLM/Whisper/TTS delegated loads.
|
|
60
|
+
*/
|
|
61
|
+
export function buildDelegateConfig(
|
|
62
|
+
providerPublicKey: string,
|
|
63
|
+
opts: { fallbackToLocal?: boolean; timeout?: number; forceNewConnection?: boolean } = {},
|
|
64
|
+
): DelegateConfig {
|
|
65
|
+
return {
|
|
66
|
+
providerPublicKey: providerPublicKey.trim(),
|
|
67
|
+
fallbackToLocal: opts.fallbackToLocal ?? false,
|
|
68
|
+
...(opts.timeout != null ? { timeout: opts.timeout } : {}),
|
|
69
|
+
...(opts.forceNewConnection != null ? { forceNewConnection: opts.forceNewConnection } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kaleidorg/mind-qvac — the single home for all @qvac/sdk logic behind
|
|
3
|
+
* @kaleidorg/mind. Hosts (rate mobile, desktop provider, cli) supply @qvac/sdk
|
|
4
|
+
* as a peer dependency; this package owns the orchestration so the logic lives
|
|
5
|
+
* in one place instead of drifting copies per host.
|
|
6
|
+
*
|
|
7
|
+
* This first slice exports the platform-agnostic core (pure text helpers, model
|
|
8
|
+
* configs, completion parsing). The QVAC-calling provider/voice/host wrappers
|
|
9
|
+
* land next, on top of these.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
cleanAssistantVisibleText,
|
|
13
|
+
sanitizeForSupertonic,
|
|
14
|
+
} from './text.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
LOCAL_LLM_CONFIG,
|
|
18
|
+
LOCAL_LLM_CONFIG_GPU,
|
|
19
|
+
DELEGATE_LLM_CONFIG,
|
|
20
|
+
TTS_SAMPLE_RATE,
|
|
21
|
+
DEFAULT_VOICE_STREAM_PARAMS,
|
|
22
|
+
WHISPER_LANGS,
|
|
23
|
+
normalizeWhisperLang,
|
|
24
|
+
} from './config.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
finalToTurn,
|
|
28
|
+
type QvacFinalLike,
|
|
29
|
+
type ParsedTurn,
|
|
30
|
+
} from './parse.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
consumeRun,
|
|
34
|
+
type CompletionEventLike,
|
|
35
|
+
type CompletionRunLike,
|
|
36
|
+
type StreamHandlers,
|
|
37
|
+
type ConsumedTurn,
|
|
38
|
+
} from './stream.js';
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
createQvacProvider,
|
|
42
|
+
type QvacProviderOptions,
|
|
43
|
+
type QvacTurnInput,
|
|
44
|
+
} from './provider.js';
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
createQvacVoice,
|
|
48
|
+
type QvacVoice,
|
|
49
|
+
type QvacVoiceOptions,
|
|
50
|
+
type VoiceSession,
|
|
51
|
+
type PcmAudio,
|
|
52
|
+
} from './voice.js';
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
runVoiceAssistant,
|
|
56
|
+
shouldHandleUtterance,
|
|
57
|
+
DEFAULT_IGNORED_UTTERANCES,
|
|
58
|
+
type VoiceAssistantSession,
|
|
59
|
+
type VoiceAssistantHandlers,
|
|
60
|
+
type VoiceAssistantOptions,
|
|
61
|
+
type VoiceAssistantState,
|
|
62
|
+
type VoiceTranscriptEvent,
|
|
63
|
+
} from './assistant.js';
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
allowListFirewall,
|
|
67
|
+
denyListFirewall,
|
|
68
|
+
firewallFromKeyList,
|
|
69
|
+
buildDelegateConfig,
|
|
70
|
+
type ProviderFirewall,
|
|
71
|
+
type DelegateConfig,
|
|
72
|
+
} from './delegate.js';
|
|
@@ -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
|
+
}
|