@kaleidorg/mind 0.6.4 → 0.8.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 (189) hide show
  1. package/README.md +345 -0
  2. package/dist/autonomy/risk.js.map +1 -1
  3. package/dist/autonomy/run-state.d.ts.map +1 -1
  4. package/dist/autonomy/run-state.js.map +1 -1
  5. package/dist/autonomy/task-store.d.ts.map +1 -1
  6. package/dist/autonomy/task-store.js.map +1 -1
  7. package/dist/bitrefill/contract.js.map +1 -1
  8. package/dist/context/budget.js.map +1 -1
  9. package/dist/context/builder.d.ts.map +1 -1
  10. package/dist/context/builder.js.map +1 -1
  11. package/dist/context/compress.d.ts.map +1 -1
  12. package/dist/context/compress.js +1 -0
  13. package/dist/context/compress.js.map +1 -1
  14. package/dist/context/rgb-units.d.ts +18 -0
  15. package/dist/context/rgb-units.d.ts.map +1 -0
  16. package/dist/context/rgb-units.js +86 -0
  17. package/dist/context/rgb-units.js.map +1 -0
  18. package/dist/engine.d.ts +53 -1
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +179 -17
  21. package/dist/engine.js.map +1 -1
  22. package/dist/evidence.d.ts +1 -1
  23. package/dist/evidence.d.ts.map +1 -1
  24. package/dist/evidence.js.map +1 -1
  25. package/dist/fastpath/fastpath.d.ts.map +1 -1
  26. package/dist/fastpath/fastpath.js.map +1 -1
  27. package/dist/flashnet/contract.js.map +1 -1
  28. package/dist/funnel.d.ts.map +1 -1
  29. package/dist/funnel.js +23 -4
  30. package/dist/funnel.js.map +1 -1
  31. package/dist/guards.d.ts +63 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +284 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/index.d.ts +10 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/kaleidoswap/contract.d.ts +3 -4
  40. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  41. package/dist/kaleidoswap/contract.js +3 -17
  42. package/dist/kaleidoswap/contract.js.map +1 -1
  43. package/dist/knowledge/btc-map.js.map +1 -1
  44. package/dist/logger.d.ts.map +1 -1
  45. package/dist/logger.js.map +1 -1
  46. package/dist/lsps1/contract.js.map +1 -1
  47. package/dist/memory/store.d.ts.map +1 -1
  48. package/dist/memory/store.js.map +1 -1
  49. package/dist/providers/openai.d.ts +64 -0
  50. package/dist/providers/openai.d.ts.map +1 -0
  51. package/dist/providers/openai.js +233 -0
  52. package/dist/providers/openai.js.map +1 -0
  53. package/dist/providers/types.d.ts +20 -0
  54. package/dist/providers/types.d.ts.map +1 -1
  55. package/dist/qvac/assistant.js.map +1 -1
  56. package/dist/qvac/config.d.ts +7 -7
  57. package/dist/qvac/config.d.ts.map +1 -1
  58. package/dist/qvac/config.js +1 -1
  59. package/dist/qvac/delegate.d.ts +2 -0
  60. package/dist/qvac/delegate.d.ts.map +1 -1
  61. package/dist/qvac/delegate.js +2 -0
  62. package/dist/qvac/delegate.js.map +1 -1
  63. package/dist/qvac/index.d.ts +2 -0
  64. package/dist/qvac/index.d.ts.map +1 -1
  65. package/dist/qvac/index.js +2 -0
  66. package/dist/qvac/index.js.map +1 -1
  67. package/dist/qvac/models.d.ts +31 -0
  68. package/dist/qvac/models.d.ts.map +1 -0
  69. package/dist/qvac/models.js +80 -0
  70. package/dist/qvac/models.js.map +1 -0
  71. package/dist/qvac/parse.d.ts +16 -2
  72. package/dist/qvac/parse.d.ts.map +1 -1
  73. package/dist/qvac/parse.js +29 -7
  74. package/dist/qvac/parse.js.map +1 -1
  75. package/dist/qvac/provider.d.ts +5 -4
  76. package/dist/qvac/provider.d.ts.map +1 -1
  77. package/dist/qvac/provider.js +30 -21
  78. package/dist/qvac/provider.js.map +1 -1
  79. package/dist/qvac/stream.d.ts +3 -5
  80. package/dist/qvac/stream.d.ts.map +1 -1
  81. package/dist/qvac/stream.js +29 -1
  82. package/dist/qvac/stream.js.map +1 -1
  83. package/dist/qvac/tools.d.ts +37 -0
  84. package/dist/qvac/tools.d.ts.map +1 -0
  85. package/dist/qvac/tools.js +94 -0
  86. package/dist/qvac/tools.js.map +1 -0
  87. package/dist/qvac/voice.js.map +1 -1
  88. package/dist/rag/retriever.d.ts.map +1 -1
  89. package/dist/rag/tool.js.map +1 -1
  90. package/dist/rag/vector-store.d.ts.map +1 -1
  91. package/dist/rag/vector-store.js.map +1 -1
  92. package/dist/recipe/issue-asset.d.ts +16 -0
  93. package/dist/recipe/issue-asset.d.ts.map +1 -0
  94. package/dist/recipe/issue-asset.js +120 -0
  95. package/dist/recipe/issue-asset.js.map +1 -0
  96. package/dist/recipe/runner.d.ts.map +1 -1
  97. package/dist/recipe/runner.js +1 -0
  98. package/dist/recipe/runner.js.map +1 -1
  99. package/dist/recipe/submarine-pay.d.ts +17 -0
  100. package/dist/recipe/submarine-pay.d.ts.map +1 -0
  101. package/dist/recipe/submarine-pay.js +79 -0
  102. package/dist/recipe/submarine-pay.js.map +1 -0
  103. package/dist/skills/loader.js.map +1 -1
  104. package/dist/skills/registry.d.ts.map +1 -1
  105. package/dist/skills/registry.js +1 -1
  106. package/dist/skills/registry.js.map +1 -1
  107. package/dist/skills/select.d.ts +20 -0
  108. package/dist/skills/select.d.ts.map +1 -0
  109. package/dist/skills/select.js +32 -0
  110. package/dist/skills/select.js.map +1 -0
  111. package/dist/submarine/contract.d.ts +42 -0
  112. package/dist/submarine/contract.d.ts.map +1 -0
  113. package/dist/submarine/contract.js +73 -0
  114. package/dist/submarine/contract.js.map +1 -0
  115. package/dist/testing/index.d.ts +14 -0
  116. package/dist/testing/index.d.ts.map +1 -0
  117. package/dist/testing/index.js +12 -0
  118. package/dist/testing/index.js.map +1 -0
  119. package/dist/testing/mock-wallet.d.ts +103 -0
  120. package/dist/testing/mock-wallet.d.ts.map +1 -0
  121. package/dist/testing/mock-wallet.js +249 -0
  122. package/dist/testing/mock-wallet.js.map +1 -0
  123. package/dist/testing/scripted-provider.d.ts +17 -0
  124. package/dist/testing/scripted-provider.d.ts.map +1 -0
  125. package/dist/testing/scripted-provider.js +26 -0
  126. package/dist/testing/scripted-provider.js.map +1 -0
  127. package/dist/tools/in-process.d.ts.map +1 -1
  128. package/dist/tools/mcp.d.ts.map +1 -1
  129. package/dist/tools/registry.d.ts.map +1 -1
  130. package/dist/tools/registry.js.map +1 -1
  131. package/dist/wallet/confirm.d.ts.map +1 -1
  132. package/dist/wallet/confirm.js +43 -5
  133. package/dist/wallet/confirm.js.map +1 -1
  134. package/dist/wallet/contract.d.ts.map +1 -1
  135. package/dist/wallet/contract.js +36 -0
  136. package/dist/wallet/contract.js.map +1 -1
  137. package/package.json +15 -5
  138. package/skills/README.md +2 -1
  139. package/skills/kaleido-node/SKILL.md +63 -0
  140. package/skills/kaleido-trading/SKILL.md +34 -27
  141. package/skills/kaleido-trading/references/api.md +61 -0
  142. package/skills/kaleido-trading/references/assets.md +57 -0
  143. package/skills/kaleido-trading/references/atomic.md +93 -0
  144. package/skills/paid-data/SKILL.md +59 -6
  145. package/skills/rgb-lightning-node/SKILL.md +142 -17
  146. package/skills/spark-wallet/SKILL.md +1 -0
  147. package/skills/submarine-swaps/SKILL.md +48 -0
  148. package/src/context/compress.ts +1 -0
  149. package/src/context/rgb-units.test.ts +42 -0
  150. package/src/context/rgb-units.ts +89 -0
  151. package/src/engine.test.ts +94 -3
  152. package/src/engine.ts +234 -19
  153. package/src/funnel.mind.test.ts +4 -2
  154. package/src/funnel.ts +23 -4
  155. package/src/guards.test.ts +399 -0
  156. package/src/guards.ts +299 -0
  157. package/src/index.ts +40 -1
  158. package/src/kaleidoswap/contract.test.ts +8 -16
  159. package/src/kaleidoswap/contract.ts +4 -32
  160. package/src/providers/openai.test.ts +127 -0
  161. package/src/providers/openai.ts +282 -0
  162. package/src/providers/types.ts +22 -0
  163. package/src/qvac/config.ts +1 -1
  164. package/src/qvac/delegate.ts +2 -0
  165. package/src/qvac/index.ts +12 -0
  166. package/src/qvac/models.ts +98 -0
  167. package/src/qvac/parse.test.ts +7 -0
  168. package/src/qvac/parse.ts +38 -3
  169. package/src/qvac/provider.test.ts +72 -1
  170. package/src/qvac/provider.ts +40 -27
  171. package/src/qvac/stream.test.ts +28 -0
  172. package/src/qvac/stream.ts +33 -6
  173. package/src/qvac/tools.test.ts +86 -0
  174. package/src/qvac/tools.ts +116 -0
  175. package/src/recipe/issue-asset.test.ts +131 -0
  176. package/src/recipe/issue-asset.ts +123 -0
  177. package/src/recipe/runner.ts +1 -0
  178. package/src/recipe/submarine-pay.test.ts +87 -0
  179. package/src/recipe/submarine-pay.ts +78 -0
  180. package/src/skills/registry.ts +1 -1
  181. package/src/skills/select.ts +41 -0
  182. package/src/submarine/contract.ts +112 -0
  183. package/src/testing/index.ts +14 -0
  184. package/src/testing/mock-wallet.ts +278 -0
  185. package/src/testing/scripted-provider.ts +37 -0
  186. package/src/wallet/confirm.test.ts +16 -0
  187. package/src/wallet/confirm.ts +43 -5
  188. package/src/wallet/contract.test.ts +38 -0
  189. package/src/wallet/contract.ts +36 -0
@@ -20,6 +20,7 @@ import type * as QvacSdk from '@qvac/sdk';
20
20
  import type { InferenceMetrics, LLMProvider, TurnInput, TurnOutput } from '../providers/types.js';
21
21
  import type { QvacTurnStats } from './parse.js';
22
22
  import { consumeRun } from './stream.js';
23
+ import { toQvacTools } from './tools.js';
23
24
 
24
25
  type CompletionFn = typeof QvacSdk.completion;
25
26
  type CancelFn = typeof QvacSdk.cancel;
@@ -40,10 +41,11 @@ export interface QvacProviderOptions {
40
41
  /** Default max output tokens — caps a turn so it can't ramble. Omit for uncapped. */
41
42
  defaultMaxTokens?: number;
42
43
  /**
43
- * Cap `<think>` reasoning at this many TOKENS (not seconds — tok/s varies, and
44
- * the SDK has no numeric reasoning budget). When a turn's thinking exceeds it,
45
- * the run is cancelled and a short fallback is returned instead of hanging on
46
- * "Thinking…". Omit for unlimited reasoning.
44
+ * Cap `<think>` reasoning at this many TOKENS (not seconds — tok/s varies).
45
+ * Sent as the SDK's `reasoning_budget`, so the model closes its reasoning and
46
+ * answers. If the stream still runs well past it, the run is cancelled and a
47
+ * short fallback is returned instead of hanging on "Thinking…". Omit for
48
+ * unlimited reasoning.
47
49
  */
48
50
  maxThinkingTokens?: number;
49
51
  /** Stream the model's `<think>` reasoning, when a host wants to surface it. */
@@ -82,31 +84,33 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
82
84
  ? [{ role: 'system', content: input.system }, ...input.messages]
83
85
  : input.messages;
84
86
 
85
- // Tools are forwarded by schema only (name/description/parameters). We
86
- // carry `parameters` through verbatim (Zod for in-process tools, JSON
87
- // Schema for MCP) — the model only needs the shape to pick a call; the
88
- // Engine validates + executes.
89
- const tools = input.tools.length
90
- ? input.tools.map((t) => ({
91
- name: t.name,
92
- description: t.description,
93
- parameters: t.parameters,
94
- }))
95
- : undefined;
96
-
97
- // QVAC 0.13 nests sampling under `generationParams`; top-level
87
+ // Tools are forwarded by schema only — the Engine validates + executes.
88
+ // JSON-Schema tools are normalised to the SDK's Tool shape (see tools.ts).
89
+ const tools = toQvacTools(input.tools);
90
+
91
+ // QVAC (0.13+) nests sampling under `generationParams`; top-level
98
92
  // `temperature`/`max_tokens` (as older rate code passed) are dropped by
99
93
  // validation, so the cap silently no-op'd. Build it here, and only send it
100
94
  // when a value is set so a host that passes neither keeps SDK defaults.
101
95
  const temp = input.temperature ?? options.defaultTemperature;
102
96
  const predict = input.maxTokens ?? options.defaultMaxTokens;
103
- const generationParams =
104
- temp !== undefined || predict !== undefined
105
- ? {
106
- ...(temp !== undefined ? { temp } : {}),
107
- ...(predict !== undefined ? { predict } : {}),
108
- }
109
- : undefined;
97
+ // A thinking budget at or above the output cap never binds: the model can
98
+ // spend the whole turn reasoning and return no answer. Keep half for it.
99
+ const thinkingCap = input.maxThinkingTokens ?? options.maxThinkingTokens;
100
+ const maxThinkingTokens =
101
+ thinkingCap !== undefined && predict !== undefined && thinkingCap >= predict
102
+ ? Math.floor(predict / 2)
103
+ : thinkingCap;
104
+ // `tool_choice` is only meaningful with tools; the SDK rejects a named
105
+ // choice that isn't among them.
106
+ const toolChoice = tools && input.toolChoice ? input.toolChoice : undefined;
107
+ const generationParamsRaw = {
108
+ ...(temp !== undefined ? { temp } : {}),
109
+ ...(predict !== undefined ? { predict } : {}),
110
+ ...(maxThinkingTokens !== undefined ? { reasoning_budget: maxThinkingTokens } : {}),
111
+ ...(toolChoice ? { tool_choice: toolChoice } : {}),
112
+ };
113
+ const generationParams = Object.keys(generationParamsRaw).length ? generationParamsRaw : undefined;
110
114
 
111
115
  const run = options.completion({
112
116
  modelId,
@@ -136,11 +140,13 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
136
140
  }
137
141
  }
138
142
 
139
- const maxThinkingTokens = input.maxThinkingTokens ?? options.maxThinkingTokens;
140
143
  const result = await consumeRun(run, {
141
144
  onToken: input.onToken,
142
145
  onThinking: input.onThinking ?? options.onThinking,
143
- maxThinkingTokens,
146
+ // Backstop only: the SDK enforces the budget itself, and our count is a
147
+ // char-based estimate, so leave headroom before cancelling.
148
+ maxThinkingTokens:
149
+ maxThinkingTokens === undefined ? undefined : Math.ceil(maxThinkingTokens * 1.25) + 32,
144
150
  // Cancel the in-flight run the moment the thinking budget is blown — the
145
151
  // SDK keeps generating otherwise. Fire-and-forget; `final` then resolves.
146
152
  onThinkingBudgetExceeded: () => {
@@ -155,8 +161,11 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
155
161
  // instead of an empty bubble so the agentic loop ends cleanly.
156
162
  const text =
157
163
  result.text || (result.thinkingBudgetExceeded ? THINKING_BUDGET_FALLBACK : result.text);
158
- const totalTokens = result.stats?.totalTokens;
159
164
  const promptTokens = result.stats?.promptTokens;
165
+ const generated = result.stats?.generatedTokens;
166
+ const totalTokens =
167
+ result.stats?.totalTokens ??
168
+ (typeof generated === 'number' && typeof promptTokens === 'number' ? promptTokens + generated : undefined);
160
169
  const inference: InferenceMetrics = {
161
170
  requestId: result.requestId,
162
171
  durationMs: result.timing.durationMs,
@@ -179,12 +188,16 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
179
188
  ...(result.stopReason ? { stopReason: result.stopReason } : {}),
180
189
  };
181
190
 
191
+ const incomplete =
192
+ !result.text && result.toolCalls.length === 0 && (result.thinkingBudgetExceeded || !!result.truncated);
182
193
  return {
183
194
  text,
184
195
  rawContent: result.rawContent,
185
196
  toolCalls: result.toolCalls,
197
+ ...(result.toolErrors ? { toolErrors: result.toolErrors } : {}),
186
198
  requestId: result.requestId,
187
199
  inference,
200
+ ...(incomplete ? { incomplete: true } : {}),
188
201
  };
189
202
  },
190
203
 
@@ -112,4 +112,32 @@ describe('consumeRun', () => {
112
112
  );
113
113
  expect(out.timing).toEqual({ ttftMs: 45, durationMs: 90 });
114
114
  });
115
+
116
+ it('folds a rejected final (InferenceCancelledError) into a cancelled turn', async () => {
117
+ class InferenceCancelledError extends Error {
118
+ constructor(readonly requestId: string, readonly partial: { text?: string }) {
119
+ super('cancelled');
120
+ }
121
+ }
122
+ const run: CompletionRunLike = {
123
+ requestId: 'req-c',
124
+ events: (async function* () {
125
+ yield { type: 'contentDelta', text: 'Partial' };
126
+ })(),
127
+ final: Promise.reject(new InferenceCancelledError('req-c', { text: 'Partial' })),
128
+ };
129
+ const out = await consumeRun(run);
130
+ expect(out.stopReason).toBe('cancelled');
131
+ expect(out.text).toBe('Partial');
132
+ expect(out.toolCalls).toEqual([]);
133
+ });
134
+
135
+ it('re-throws a rejected final that is not a cancellation', async () => {
136
+ const run: CompletionRunLike = {
137
+ requestId: 'req-e',
138
+ events: (async function* () {})(),
139
+ final: Promise.reject(new Error('boom')),
140
+ };
141
+ await expect(consumeRun(run)).rejects.toThrow('boom');
142
+ });
115
143
  });
@@ -28,11 +28,9 @@ export interface StreamHandlers {
28
28
  /** The model's `<think>` reasoning, streamed separately. */
29
29
  onThinking?: (token: string) => void;
30
30
  /**
31
- * Cap the `<think>` reasoning at this many tokens. The cap is on TOKENS, not
32
- * wall-clock seconds — tok/s varies by model and hardware, so a time budget is
33
- * unreliable; the SDK has no numeric reasoning budget (`reasoning_budget` is
34
- * only on/off), so we count thinking tokens and stop the run once they exceed
35
- * this. Omit for unlimited reasoning.
31
+ * Stop the run once `<think>` reasoning exceeds this many tokens (estimated
32
+ * from characters). A backstop for the SDK's own `reasoning_budget`. Omit for
33
+ * unlimited reasoning.
36
34
  */
37
35
  maxThinkingTokens?: number;
38
36
  /**
@@ -55,6 +53,28 @@ export interface ConsumedTurn extends ParsedTurn {
55
53
  };
56
54
  }
57
55
 
56
+ /**
57
+ * Since QVAC 0.11 a cancelled run ends `events` normally but REJECTS `final`
58
+ * with `InferenceCancelledError { requestId, partial }`. Fold that back into a
59
+ * `final` frame with stopReason 'cancelled' so a stop or a blown thinking budget
60
+ * is a result, not an exception. Matched structurally to stay SDK-free.
61
+ */
62
+ function cancelledFinal(err: unknown): QvacFinalLike | null {
63
+ const e = err as {
64
+ requestId?: unknown;
65
+ partial?: { text?: string; toolCalls?: QvacFinalLike['toolCalls']; stats?: QvacFinalLike['stats'] };
66
+ } | null;
67
+ const named = (err as { constructor?: { name?: string } } | null)?.constructor?.name === 'InferenceCancelledError';
68
+ if (!e || !(named || (typeof e.requestId === 'string' && typeof e.partial === 'object'))) return null;
69
+ const partial = e.partial ?? {};
70
+ return {
71
+ contentText: partial.text ?? '',
72
+ toolCalls: partial.toolCalls ?? [],
73
+ ...(partial.stats ? { stats: partial.stats } : {}),
74
+ stopReason: 'cancelled',
75
+ };
76
+ }
77
+
58
78
  /** Rough token estimate (~4 chars/token) — same heuristic the context budget uses. */
59
79
  function approxTokens(chars: number): number {
60
80
  return Math.ceil(chars / 4);
@@ -95,7 +115,14 @@ export async function consumeRun(
95
115
  }
96
116
  }
97
117
  }
98
- const final = await run.final;
118
+ let final: QvacFinalLike;
119
+ try {
120
+ final = await run.final;
121
+ } catch (err) {
122
+ const recovered = cancelledFinal(err);
123
+ if (!recovered) throw err;
124
+ final = recovered;
125
+ }
99
126
  const finishedAt = now();
100
127
  return {
101
128
  ...finalToTurn(final, streamed),
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toQvacTools } from './tools.js';
3
+ import { WALLET_TOOLS } from '../wallet/contract.js';
4
+
5
+ describe('toQvacTools', () => {
6
+ it('returns undefined for no tools', () => {
7
+ expect(toQvacTools([])).toBeUndefined();
8
+ });
9
+
10
+ it('keeps JSON-Schema parameters as a full function tool', () => {
11
+ const [tool] = toQvacTools([
12
+ {
13
+ name: 'rln_send_btc',
14
+ description: 'send',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ address: { type: 'string', description: 'Destination' },
19
+ amount_sat: { type: 'integer' },
20
+ note: { type: ['string', 'null'] },
21
+ mode: { anyOf: [{ type: 'null' }, { type: 'boolean' }] },
22
+ tags: { type: 'array', items: { type: 'string' } },
23
+ schema: { type: 'string', enum: ['NIA', 'CFA'] },
24
+ },
25
+ required: ['address', 'amount_sat', 'ghost'],
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ ])!;
30
+ expect(tool).toEqual({
31
+ type: 'function',
32
+ name: 'rln_send_btc',
33
+ description: 'send',
34
+ parameters: {
35
+ type: 'object',
36
+ properties: {
37
+ address: { type: 'string', description: 'Destination' },
38
+ amount_sat: { type: 'integer' },
39
+ note: { type: 'string' },
40
+ mode: { type: 'boolean' },
41
+ tags: { type: 'array' },
42
+ schema: { type: 'string', enum: ['NIA', 'CFA'] },
43
+ },
44
+ required: ['address', 'amount_sat'],
45
+ },
46
+ });
47
+ });
48
+
49
+ it('passes an all-Zod tool list through for the SDK to convert', () => {
50
+ const zodish = { shape: {} };
51
+ expect(toQvacTools([{ name: 'a', description: 'd', parameters: zodish }])).toEqual([
52
+ { name: 'a', description: 'd', parameters: zodish },
53
+ ]);
54
+ });
55
+
56
+ it('converts Zod-like tools when mixed with JSON-Schema tools', () => {
57
+ const zodish = {
58
+ shape: {
59
+ city: { type: 'string', description: 'City' },
60
+ unit: { type: 'optional', def: { innerType: { type: 'enum', def: { entries: { c: 'c', f: 'f' } } } } },
61
+ },
62
+ };
63
+ const out = toQvacTools([
64
+ { name: 'weather', description: 'w', parameters: zodish },
65
+ { name: 'ping', description: 'p', parameters: { type: 'object', properties: {} } },
66
+ ])!;
67
+ expect(out[0]).toEqual({
68
+ type: 'function',
69
+ name: 'weather',
70
+ description: 'w',
71
+ parameters: {
72
+ type: 'object',
73
+ properties: { city: { type: 'string', description: 'City' }, unit: { type: 'string', enum: ['c', 'f'] } },
74
+ required: ['city'],
75
+ },
76
+ });
77
+ });
78
+
79
+ it('every wallet contract tool keeps its arguments', () => {
80
+ const out = toQvacTools(WALLET_TOOLS) as Array<{ name: string; parameters: { properties: object } }>;
81
+ for (const def of WALLET_TOOLS) {
82
+ const mapped = out.find((t) => t.name === def.name)!;
83
+ expect(Object.keys(mapped.parameters.properties)).toEqual(Object.keys((def.parameters as any).properties));
84
+ }
85
+ });
86
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Map engine ToolDefs to the tool shape `completion({ tools })` accepts.
3
+ *
4
+ * The SDK picks a mode from the FIRST tool: a full `Tool`
5
+ * (`{ type: 'function', name, description, parameters: <flat JSON Schema> }`)
6
+ * or a Zod `ToolInput`. A plain JSON-Schema def without `type: 'function'`
7
+ * falls into the Zod branch, which reads `parameters.shape` and silently drops
8
+ * every argument — the model then sees tool names with no parameters. So
9
+ * JSON-Schema tools (wallet contract, MCP) are normalised to full `Tool`s here.
10
+ * An all-Zod tool list is passed through for the SDK to convert, as before.
11
+ */
12
+ import type { ToolDef } from '../types.js';
13
+
14
+ type PropType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'integer';
15
+ const PROP_TYPES = new Set<PropType>(['string', 'number', 'boolean', 'object', 'array', 'integer']);
16
+
17
+ export interface QvacToolProp {
18
+ type: PropType;
19
+ description?: string;
20
+ enum?: Array<string | number | boolean | null>;
21
+ }
22
+
23
+ export interface QvacTool {
24
+ type: 'function';
25
+ name: string;
26
+ description: string;
27
+ parameters: { type: 'object'; properties: Record<string, QvacToolProp>; required?: string[] };
28
+ }
29
+
30
+ type Obj = Record<string, any>;
31
+
32
+ function isZodLike(p: unknown): boolean {
33
+ const o = p as Obj | null;
34
+ return !!o && typeof o === 'object' && ('shape' in o || '_def' in o || 'def' in o) && !('properties' in o);
35
+ }
36
+
37
+ function pickType(s: Obj): PropType {
38
+ const t = Array.isArray(s.type) ? s.type.find((x: unknown) => x !== 'null') : s.type;
39
+ if (PROP_TYPES.has(t)) return t;
40
+ for (const alt of [...(s.anyOf ?? []), ...(s.oneOf ?? [])]) {
41
+ if (alt && typeof alt === 'object' && alt.type !== 'null') return pickType(alt);
42
+ }
43
+ if (Array.isArray(s.enum) && s.enum.length) {
44
+ const first = typeof s.enum[0];
45
+ if (first === 'number' || first === 'boolean') return first;
46
+ }
47
+ return 'string';
48
+ }
49
+
50
+ function jsonProp(s: Obj): QvacToolProp {
51
+ const prop: QvacToolProp = { type: pickType(s ?? {}) };
52
+ if (typeof s?.description === 'string') prop.description = s.description;
53
+ if (Array.isArray(s?.enum)) {
54
+ const values = s.enum.filter((v: unknown) => v === null || ['string', 'number', 'boolean'].includes(typeof v));
55
+ if (values.length) prop.enum = values;
56
+ }
57
+ return prop;
58
+ }
59
+
60
+ function zodProp(field: Obj): { prop: QvacToolProp; optional: boolean } {
61
+ const kind = field?.type ?? field?.def?.type ?? 'string';
62
+ const optional = kind === 'optional';
63
+ const inner = optional ? field.def?.innerType ?? {} : field;
64
+ const innerKind = inner.type ?? inner.def?.type ?? 'string';
65
+ const prop: QvacToolProp = { type: PROP_TYPES.has(innerKind) ? innerKind : 'string' };
66
+ const description = field.description ?? inner.description;
67
+ if (description) prop.description = description;
68
+ const values = inner.def?.values ?? inner.def?.entries;
69
+ if (Array.isArray(values)) prop.enum = values;
70
+ else if (values && typeof values === 'object') prop.enum = Object.values(values);
71
+ return { prop, optional };
72
+ }
73
+
74
+ function toQvacTool(t: Pick<ToolDef, 'name' | 'description' | 'parameters'>): QvacTool {
75
+ const p = (t.parameters ?? {}) as Obj;
76
+ const properties: Record<string, QvacToolProp> = {};
77
+ const required: string[] = [];
78
+ if (isZodLike(p)) {
79
+ const shape = p.shape ?? p.def?.shape ?? {};
80
+ for (const [key, field] of Object.entries(shape as Obj)) {
81
+ const { prop, optional } = zodProp(field as Obj);
82
+ properties[key] = prop;
83
+ if (!optional) required.push(key);
84
+ }
85
+ } else {
86
+ for (const [key, s] of Object.entries((p.properties ?? {}) as Obj)) properties[key] = jsonProp(s as Obj);
87
+ for (const key of Array.isArray(p.required) ? p.required : []) if (key in properties) required.push(key);
88
+ }
89
+ return {
90
+ type: 'function',
91
+ name: t.name,
92
+ description: t.description ?? '',
93
+ parameters: { type: 'object', properties, ...(required.length ? { required } : {}) },
94
+ };
95
+ }
96
+
97
+ /** Tools for `completion()`; undefined when there are none. */
98
+ export function toQvacTools(
99
+ tools: ReadonlyArray<Pick<ToolDef, 'name' | 'description' | 'parameters'>>,
100
+ ): QvacTool[] | Array<Pick<ToolDef, 'name' | 'description' | 'parameters'>> | undefined {
101
+ if (!tools.length) return undefined;
102
+ if (tools.every((t) => isZodLike(t.parameters))) {
103
+ return tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters }));
104
+ }
105
+ return tools.map(toQvacTool);
106
+ }
107
+
108
+ /**
109
+ * JSON Schema for a tool's parameters: a JSON-Schema def is returned as is
110
+ * (nested objects intact), a Zod schema is converted to a flat object schema.
111
+ */
112
+ export function toolParametersSchema(t: Pick<ToolDef, 'name' | 'description' | 'parameters'>): Record<string, unknown> {
113
+ const p = t.parameters as Obj | null | undefined;
114
+ if (p && typeof p === 'object' && !isZodLike(p) && ('properties' in p || p.type === 'object')) return p;
115
+ return toQvacTool(t).parameters;
116
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { extractIssueAsset, issueAssetRecipe } from './issue-asset.js';
3
+ import { runRecipe } from './runner.js';
4
+ import { Funnel } from '../funnel.js';
5
+ import { confirmReadback } from '../wallet/confirm.js';
6
+ import { MockWallet, scriptedProvider } from '../testing/index.js';
7
+
8
+ describe('extractIssueAsset (deterministic Tier-0)', () => {
9
+ it('ticker + supply + quoted-less name', () => {
10
+ expect(extractIssueAsset('issue 1000 TICKET tokens called Hackathon Ticket')).toEqual({
11
+ name: 'Hackathon Ticket', ticker: 'TICKET', amount: 1000, schema: 'NIA',
12
+ });
13
+ });
14
+ it('explicit ticker keyword and k shorthand', () => {
15
+ expect(extractIssueAsset('mint 21k tokens named "Pizza Points" with ticker pizza')).toEqual({
16
+ name: 'Pizza Points', ticker: 'PIZZA', amount: 21_000, schema: 'NIA',
17
+ });
18
+ });
19
+ it('NFT → UDA with supply 1, ticker derived from the name', () => {
20
+ expect(extractIssueAsset('mint an NFT called Genesis Badge')).toEqual({
21
+ name: 'Genesis Badge', ticker: 'GENESISB', amount: 1, schema: 'UDA',
22
+ });
23
+ });
24
+ it('Italian phrasing', () => {
25
+ expect(extractIssueAsset('crea 500 token CAFE chiamati Caffè Club')).toEqual({
26
+ name: 'Caffè Club', ticker: 'CAFE', amount: 500, schema: 'NIA',
27
+ });
28
+ });
29
+ it('ignores invoices, channels, sends and swaps', () => {
30
+ expect(extractIssueAsset('create an invoice for 10 USDT tokens')).toBeNull();
31
+ expect(extractIssueAsset('create a channel with 100 USDT')).toBeNull();
32
+ expect(extractIssueAsset('send 10 tokens to bob')).toBeNull();
33
+ expect(extractIssueAsset('what is my balance')).toBeNull();
34
+ expect(issueAssetRecipe.match!('buy 100 USDT tokens')).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('extractIssueAsset — review regressions', () => {
39
+ it('reads thousands separators in both styles, and decimals with k/m', () => {
40
+ expect(extractIssueAsset('crea 1.000 token CAFE chiamati Caffè Club')).toMatchObject({ amount: 1000, ticker: 'CAFE', name: 'Caffè Club' });
41
+ expect(extractIssueAsset('issue 1,000 TICKET tokens')).toMatchObject({ amount: 1000 });
42
+ expect(extractIssueAsset('mint 1,5k PIZZA tokens')).toMatchObject({ amount: 1500 });
43
+ expect(extractIssueAsset('mint 1.5k PIZZA tokens')).toMatchObject({ amount: 1500 });
44
+ });
45
+ it('never takes the supply from a number inside the name', () => {
46
+ expect(extractIssueAsset('mint tokens named Web3 Summit 2026, supply 300')).toMatchObject({ name: 'Web3 Summit 2026', amount: 300 });
47
+ expect(extractIssueAsset('mint tokens named Web3 Summit 2026')).toMatchObject({ name: 'Web3 Summit 2026', amount: undefined });
48
+ expect(issueAssetRecipe.confident!(extractIssueAsset('mint tokens named Web3 Summit 2026')!)).toBe(false);
49
+ expect(extractIssueAsset('launch a token called Moon with ticker MOON and 1m supply')).toMatchObject({ name: 'Moon', ticker: 'MOON', amount: 1_000_000 });
50
+ });
51
+ it('only fires when the request opens with the verb', () => {
52
+ expect(issueAssetRecipe.match!('I have an issue with my USDT tokens')).toBe(false);
53
+ expect(issueAssetRecipe.match!('how do I create a token?')).toBe(false);
54
+ expect(extractIssueAsset('I have an issue with my USDT tokens')).toBeNull();
55
+ expect(issueAssetRecipe.match!('please mint 100 PIZZA tokens')).toBe(true);
56
+ expect(issueAssetRecipe.match!('puoi creare 100 token CAFE?')).toBe(true);
57
+ });
58
+ it('apostrophes inside a name are not quotes', () => {
59
+ expect(extractIssueAsset("mint 100 tokens called Joe's Pizza with ticker 'JOE'")).toEqual({ name: "Joe's Pizza", ticker: 'JOE', amount: 100, schema: 'NIA' });
60
+ expect(extractIssueAsset("crea 50 token chiamati Bottega dell'Arte con ticker ARTE")).toMatchObject({ name: "Bottega dell'Arte", ticker: 'ARTE', amount: 50 });
61
+ expect(extractIssueAsset('mint 10 tokens "Pizza Points" ticker PP')).toMatchObject({ name: 'Pizza Points', ticker: 'PP', amount: 10 });
62
+ });
63
+ });
64
+
65
+ describe('MockWallet.reset', () => {
66
+ it('restores the initial state, including issued assets and UTXOs', async () => {
67
+ const wallet = new MockWallet({ utxos: 1 });
68
+ await wallet.registry().execute('rln_issue_asset', { name: 'Ticket', ticker: 'TICKET', amount: 10 });
69
+ expect(wallet.utxos).toBe(0);
70
+ wallet.reset();
71
+ expect(wallet.utxos).toBe(1);
72
+ expect(wallet.assets.TICKET).toBeUndefined();
73
+ await expect(wallet.registry().execute('rln_issue_asset', { name: 'Ticket', ticker: 'TICKET', amount: 10 })).resolves.toMatchObject({ issued: true });
74
+ });
75
+ });
76
+
77
+ describe('issue-asset readback', () => {
78
+ it('fungible: supply + ticker + name', () => {
79
+ expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Hackathon Ticket', ticker: 'TICKET', amount: 1000 } }))
80
+ .toBe('Issue 1,000 TICKET (Hackathon Ticket), a new RGB asset. Confirm?');
81
+ });
82
+ it('UDA: unique asset wording', () => {
83
+ expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Genesis Badge', ticker: 'GENESISB', amount: 1, schema: 'UDA' } }))
84
+ .toBe('Issue unique asset GENESISB (Genesis Badge) on RGB. Confirm?');
85
+ });
86
+ });
87
+
88
+ describe('runRecipe — issue asset against MockWallet', () => {
89
+ it('one confirmation, asset lands in the wallet', async () => {
90
+ const wallet = new MockWallet();
91
+ const onConfirm = vi.fn(async () => ({ approved: true }));
92
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens called Hackathon Ticket', {
93
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm,
94
+ });
95
+ expect(res.status).toBe('done');
96
+ expect(res.inferences).toBe(0);
97
+ expect(onConfirm).toHaveBeenCalledOnce();
98
+ expect(wallet.assets.TICKET).toBe(1000);
99
+ expect(res.text).toMatch(/^Issued 1000 TICKET\. Asset id: rgb:mock-ticket-/);
100
+ });
101
+
102
+ it('declined confirmation issues nothing', async () => {
103
+ const wallet = new MockWallet();
104
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens', {
105
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: false }),
106
+ });
107
+ expect(res.status).toBe('cancelled');
108
+ expect(wallet.assets.TICKET).toBeUndefined();
109
+ });
110
+
111
+ it('surfaces the node-style "no UTXOs" failure', async () => {
112
+ const wallet = new MockWallet({ utxos: 0 });
113
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens', {
114
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: true }),
115
+ });
116
+ expect(res.status).toBe('error');
117
+ expect(res.error ?? res.text).toMatch(/rln_create_utxos/);
118
+ });
119
+ });
120
+
121
+ describe('Funnel — issue-asset as an opt-in recipe', () => {
122
+ it('routes to the recipe tier and is listable afterwards', async () => {
123
+ const wallet = new MockWallet();
124
+ const funnel = new Funnel({ provider: scriptedProvider(), tools: wallet.registry(), recipes: [issueAssetRecipe] });
125
+ const out = await funnel.runTurn('mint an NFT called Genesis Badge', { onConfirm: async () => ({ approved: true }) });
126
+ expect(out.tier).toBe('recipe');
127
+ expect(out.route).toBe('issue-asset');
128
+ const listed = (await wallet.registry().execute('rln_list_assets', {})) as { assets: Array<{ ticker: string; schema: string }> };
129
+ expect(listed.assets.find((a) => a.ticker === 'GENESISB')?.schema).toBe('UDA');
130
+ });
131
+ });