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