@kaleidorg/mind 0.6.3 → 0.7.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 (127) hide show
  1. package/README.md +257 -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.js.map +1 -1
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js.map +1 -1
  14. package/dist/evidence.d.ts +1 -1
  15. package/dist/evidence.d.ts.map +1 -1
  16. package/dist/evidence.js.map +1 -1
  17. package/dist/fastpath/fastpath.d.ts.map +1 -1
  18. package/dist/fastpath/fastpath.js.map +1 -1
  19. package/dist/flashnet/contract.js.map +1 -1
  20. package/dist/funnel.d.ts +3 -0
  21. package/dist/funnel.d.ts.map +1 -1
  22. package/dist/funnel.js +2 -0
  23. package/dist/funnel.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/kaleidoswap/contract.js.map +1 -1
  29. package/dist/knowledge/btc-map.js.map +1 -1
  30. package/dist/logger.d.ts.map +1 -1
  31. package/dist/logger.js.map +1 -1
  32. package/dist/lsps1/contract.js.map +1 -1
  33. package/dist/memory/store.d.ts.map +1 -1
  34. package/dist/memory/store.js.map +1 -1
  35. package/dist/qvac/assistant.js.map +1 -1
  36. package/dist/qvac/config.d.ts +6 -6
  37. package/dist/qvac/config.d.ts.map +1 -1
  38. package/dist/qvac/delegate.d.ts +2 -0
  39. package/dist/qvac/delegate.d.ts.map +1 -1
  40. package/dist/qvac/delegate.js +2 -0
  41. package/dist/qvac/delegate.js.map +1 -1
  42. package/dist/qvac/index.d.ts +1 -0
  43. package/dist/qvac/index.d.ts.map +1 -1
  44. package/dist/qvac/index.js +1 -0
  45. package/dist/qvac/index.js.map +1 -1
  46. package/dist/qvac/parse.d.ts +5 -2
  47. package/dist/qvac/parse.d.ts.map +1 -1
  48. package/dist/qvac/parse.js.map +1 -1
  49. package/dist/qvac/provider.d.ts.map +1 -1
  50. package/dist/qvac/provider.js +21 -13
  51. package/dist/qvac/provider.js.map +1 -1
  52. package/dist/qvac/stream.d.ts.map +1 -1
  53. package/dist/qvac/stream.js +29 -1
  54. package/dist/qvac/stream.js.map +1 -1
  55. package/dist/qvac/tools.d.ts +32 -0
  56. package/dist/qvac/tools.d.ts.map +1 -0
  57. package/dist/qvac/tools.js +84 -0
  58. package/dist/qvac/tools.js.map +1 -0
  59. package/dist/qvac/voice.js.map +1 -1
  60. package/dist/rag/retriever.d.ts.map +1 -1
  61. package/dist/rag/tool.js.map +1 -1
  62. package/dist/rag/vector-store.d.ts.map +1 -1
  63. package/dist/rag/vector-store.js.map +1 -1
  64. package/dist/recipe/issue-asset.d.ts +16 -0
  65. package/dist/recipe/issue-asset.d.ts.map +1 -0
  66. package/dist/recipe/issue-asset.js +94 -0
  67. package/dist/recipe/issue-asset.js.map +1 -0
  68. package/dist/recipe/runner.d.ts +1 -1
  69. package/dist/recipe/runner.d.ts.map +1 -1
  70. package/dist/recipe/runner.js +29 -6
  71. package/dist/recipe/runner.js.map +1 -1
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/registry.d.ts.map +1 -1
  74. package/dist/skills/registry.js.map +1 -1
  75. package/dist/testing/index.d.ts +14 -0
  76. package/dist/testing/index.d.ts.map +1 -0
  77. package/dist/testing/index.js +12 -0
  78. package/dist/testing/index.js.map +1 -0
  79. package/dist/testing/mock-wallet.d.ts +79 -0
  80. package/dist/testing/mock-wallet.d.ts.map +1 -0
  81. package/dist/testing/mock-wallet.js +180 -0
  82. package/dist/testing/mock-wallet.js.map +1 -0
  83. package/dist/testing/scripted-provider.d.ts +17 -0
  84. package/dist/testing/scripted-provider.d.ts.map +1 -0
  85. package/dist/testing/scripted-provider.js +26 -0
  86. package/dist/testing/scripted-provider.js.map +1 -0
  87. package/dist/tools/in-process.d.ts.map +1 -1
  88. package/dist/tools/mcp.d.ts.map +1 -1
  89. package/dist/tools/registry.d.ts.map +1 -1
  90. package/dist/tools/registry.js.map +1 -1
  91. package/dist/wallet/confirm.d.ts.map +1 -1
  92. package/dist/wallet/confirm.js +21 -1
  93. package/dist/wallet/confirm.js.map +1 -1
  94. package/dist/wallet/contract.d.ts.map +1 -1
  95. package/dist/wallet/contract.js +36 -0
  96. package/dist/wallet/contract.js.map +1 -1
  97. package/package.json +10 -5
  98. package/skills/README.md +1 -1
  99. package/skills/kaleido-node/SKILL.md +63 -0
  100. package/skills/kaleido-trading/SKILL.md +15 -2
  101. package/skills/kaleido-trading/references/api.md +61 -0
  102. package/skills/kaleido-trading/references/assets.md +57 -0
  103. package/skills/kaleido-trading/references/atomic.md +93 -0
  104. package/skills/paid-data/SKILL.md +59 -6
  105. package/skills/rgb-lightning-node/SKILL.md +116 -11
  106. package/src/funnel.ts +5 -0
  107. package/src/index.ts +3 -0
  108. package/src/qvac/delegate.ts +2 -0
  109. package/src/qvac/index.ts +2 -0
  110. package/src/qvac/parse.ts +5 -2
  111. package/src/qvac/provider.test.ts +38 -0
  112. package/src/qvac/provider.ts +27 -14
  113. package/src/qvac/stream.test.ts +28 -0
  114. package/src/qvac/stream.ts +30 -1
  115. package/src/qvac/tools.test.ts +86 -0
  116. package/src/qvac/tools.ts +106 -0
  117. package/src/recipe/issue-asset.test.ts +92 -0
  118. package/src/recipe/issue-asset.ts +95 -0
  119. package/src/recipe/kaleidoswap-atomic.test.ts +27 -0
  120. package/src/recipe/runner.ts +24 -5
  121. package/src/testing/index.ts +14 -0
  122. package/src/testing/mock-wallet.ts +211 -0
  123. package/src/testing/scripted-provider.ts +37 -0
  124. package/src/wallet/confirm.test.ts +16 -0
  125. package/src/wallet/confirm.ts +21 -1
  126. package/src/wallet/contract.test.ts +38 -0
  127. package/src/wallet/contract.ts +36 -0
@@ -77,6 +77,18 @@ describe('createQvacProvider.runTurn', () => {
77
77
  expect(params.generationParams).toEqual({ temp: 0.9, predict: 99 });
78
78
  });
79
79
 
80
+ it('sends JSON-Schema tools in the SDK Tool shape so arguments survive', 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({
84
+ messages: [{ role: 'user', content: 'x' }],
85
+ tools: [{ name: 'echo', description: 'e', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }],
86
+ });
87
+ expect(calls[0].tools).toEqual([
88
+ { type: 'function', name: 'echo', description: 'e', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } },
89
+ ]);
90
+ });
91
+
80
92
  it('omits generationParams when no temperature/maxTokens is set (keeps SDK defaults)', async () => {
81
93
  const { fn, calls } = fakeCompletion({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } });
82
94
  const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
@@ -101,6 +113,32 @@ describe('createQvacProvider.runTurn', () => {
101
113
  expect(out.text).toMatch(/thinking budget/i);
102
114
  });
103
115
 
116
+ it('returns a cancelled turn when the SDK rejects final on abort', async () => {
117
+ const cancel = vi.fn(async () => {});
118
+ const fn = () => ({
119
+ requestId: 'req-a',
120
+ events: (async function* () {})(),
121
+ final: Promise.reject(Object.assign(new Error('cancelled'), { requestId: 'req-a', partial: {} })),
122
+ });
123
+ const ac = new AbortController();
124
+ ac.abort();
125
+ const p = createQvacProvider({ completion: fn as any, cancel: cancel as any, getModelId: () => 'm1' });
126
+ const out = await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [], signal: ac.signal });
127
+ expect(cancel).toHaveBeenCalledWith({ requestId: 'req-a' });
128
+ expect(out.inference?.status).toBe('cancelled');
129
+ expect(out.toolCalls).toEqual([]);
130
+ });
131
+
132
+ it('derives token counts from the SDK generatedTokens stat', async () => {
133
+ const { fn } = fakeCompletion({
134
+ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' },
135
+ stats: { promptTokens: 100, generatedTokens: 20, tokensPerSecond: 12, backendDevice: 'gpu' },
136
+ });
137
+ const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
138
+ const out = await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [] });
139
+ expect(out.inference).toMatchObject({ promptTokens: 100, totalTokens: 120, completionTokens: 20, backendDevice: 'gpu' });
140
+ });
141
+
104
142
  it('streams visible content tokens to onToken', async () => {
105
143
  const { fn } = fakeCompletion(
106
144
  { contentText: 'Hi there', toolCalls: [], raw: { fullText: 'Hi there' } },
@@ -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;
@@ -82,19 +83,11 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
82
83
  ? [{ role: 'system', content: input.system }, ...input.messages]
83
84
  : input.messages;
84
85
 
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
86
+ // Tools are forwarded by schema only — the Engine validates + executes.
87
+ // JSON-Schema tools are normalised to the SDK's Tool shape (see tools.ts).
88
+ const tools = toQvacTools(input.tools);
89
+
90
+ // QVAC (0.13+) nests sampling under `generationParams`; top-level
98
91
  // `temperature`/`max_tokens` (as older rate code passed) are dropped by
99
92
  // validation, so the cap silently no-op'd. Build it here, and only send it
100
93
  // when a value is set so a host that passes neither keeps SDK defaults.
@@ -119,6 +112,23 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
119
112
  ...(tools ? { tools } : {}),
120
113
  } as unknown as Parameters<CompletionFn>[0]);
121
114
 
115
+ // Honor an external AbortSignal (e.g. the desktop "stop" button): cancel
116
+ // the in-flight run the moment it fires. The SDK keeps generating
117
+ // otherwise; `final` then resolves with stopReason 'cancelled'. This is
118
+ // the single place that makes signal-based cancellation work for EVERY
119
+ // caller (agentic loop + recipe slot extraction).
120
+ if (input.signal) {
121
+ if (input.signal.aborted) {
122
+ void options.cancel({ requestId: run.requestId }).catch(() => {});
123
+ } else {
124
+ input.signal.addEventListener(
125
+ 'abort',
126
+ () => void options.cancel({ requestId: run.requestId }).catch(() => {}),
127
+ { once: true },
128
+ );
129
+ }
130
+ }
131
+
122
132
  const maxThinkingTokens = input.maxThinkingTokens ?? options.maxThinkingTokens;
123
133
  const result = await consumeRun(run, {
124
134
  onToken: input.onToken,
@@ -138,8 +148,11 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
138
148
  // instead of an empty bubble so the agentic loop ends cleanly.
139
149
  const text =
140
150
  result.text || (result.thinkingBudgetExceeded ? THINKING_BUDGET_FALLBACK : result.text);
141
- const totalTokens = result.stats?.totalTokens;
142
151
  const promptTokens = result.stats?.promptTokens;
152
+ const generated = result.stats?.generatedTokens;
153
+ const totalTokens =
154
+ result.stats?.totalTokens ??
155
+ (typeof generated === 'number' && typeof promptTokens === 'number' ? promptTokens + generated : undefined);
143
156
  const inference: InferenceMetrics = {
144
157
  requestId: result.requestId,
145
158
  durationMs: result.timing.durationMs,
@@ -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
  });
@@ -55,6 +55,28 @@ export interface ConsumedTurn extends ParsedTurn {
55
55
  };
56
56
  }
57
57
 
58
+ /**
59
+ * Since QVAC 0.11 a cancelled run ends `events` normally but REJECTS `final`
60
+ * with `InferenceCancelledError { requestId, partial }`. Fold that back into a
61
+ * `final` frame with stopReason 'cancelled' so a stop or a blown thinking budget
62
+ * is a result, not an exception. Matched structurally to stay SDK-free.
63
+ */
64
+ function cancelledFinal(err: unknown): QvacFinalLike | null {
65
+ const e = err as {
66
+ requestId?: unknown;
67
+ partial?: { text?: string; toolCalls?: QvacFinalLike['toolCalls']; stats?: QvacFinalLike['stats'] };
68
+ } | null;
69
+ const named = (err as { constructor?: { name?: string } } | null)?.constructor?.name === 'InferenceCancelledError';
70
+ if (!e || !(named || (typeof e.requestId === 'string' && typeof e.partial === 'object'))) return null;
71
+ const partial = e.partial ?? {};
72
+ return {
73
+ contentText: partial.text ?? '',
74
+ toolCalls: partial.toolCalls ?? [],
75
+ ...(partial.stats ? { stats: partial.stats } : {}),
76
+ stopReason: 'cancelled',
77
+ };
78
+ }
79
+
58
80
  /** Rough token estimate (~4 chars/token) — same heuristic the context budget uses. */
59
81
  function approxTokens(chars: number): number {
60
82
  return Math.ceil(chars / 4);
@@ -95,7 +117,14 @@ export async function consumeRun(
95
117
  }
96
118
  }
97
119
  }
98
- const final = await run.final;
120
+ let final: QvacFinalLike;
121
+ try {
122
+ final = await run.final;
123
+ } catch (err) {
124
+ const recovered = cancelledFinal(err);
125
+ if (!recovered) throw err;
126
+ final = recovered;
127
+ }
99
128
  const finishedAt = now();
100
129
  return {
101
130
  ...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,106 @@
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
+ }
@@ -0,0 +1,92 @@
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('issue-asset readback', () => {
39
+ it('fungible: supply + ticker + name', () => {
40
+ expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Hackathon Ticket', ticker: 'TICKET', amount: 1000 } }))
41
+ .toBe('Issue 1,000 TICKET (Hackathon Ticket), a new RGB asset. Confirm?');
42
+ });
43
+ it('UDA: unique asset wording', () => {
44
+ expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Genesis Badge', ticker: 'GENESISB', amount: 1, schema: 'UDA' } }))
45
+ .toBe('Issue unique asset GENESISB (Genesis Badge) on RGB. Confirm?');
46
+ });
47
+ });
48
+
49
+ describe('runRecipe — issue asset against MockWallet', () => {
50
+ it('one confirmation, asset lands in the wallet', async () => {
51
+ const wallet = new MockWallet();
52
+ const onConfirm = vi.fn(async () => ({ approved: true }));
53
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens called Hackathon Ticket', {
54
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm,
55
+ });
56
+ expect(res.status).toBe('done');
57
+ expect(res.inferences).toBe(0);
58
+ expect(onConfirm).toHaveBeenCalledOnce();
59
+ expect(wallet.assets.TICKET).toBe(1000);
60
+ expect(res.text).toMatch(/^Issued 1000 TICKET\. Asset id: rgb:mock-ticket-/);
61
+ });
62
+
63
+ it('declined confirmation issues nothing', async () => {
64
+ const wallet = new MockWallet();
65
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens', {
66
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: false }),
67
+ });
68
+ expect(res.status).toBe('cancelled');
69
+ expect(wallet.assets.TICKET).toBeUndefined();
70
+ });
71
+
72
+ it('surfaces the node-style "no UTXOs" failure', async () => {
73
+ const wallet = new MockWallet({ utxos: 0 });
74
+ const res = await runRecipe(issueAssetRecipe, 'issue 1000 TICKET tokens', {
75
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: true }),
76
+ });
77
+ expect(res.status).toBe('error');
78
+ expect(res.error ?? res.text).toMatch(/rln_create_utxos/);
79
+ });
80
+ });
81
+
82
+ describe('Funnel — issue-asset as an opt-in recipe', () => {
83
+ it('routes to the recipe tier and is listable afterwards', async () => {
84
+ const wallet = new MockWallet();
85
+ const funnel = new Funnel({ provider: scriptedProvider(), tools: wallet.registry(), recipes: [issueAssetRecipe] });
86
+ const out = await funnel.runTurn('mint an NFT called Genesis Badge', { onConfirm: async () => ({ approved: true }) });
87
+ expect(out.tier).toBe('recipe');
88
+ expect(out.route).toBe('issue-asset');
89
+ const listed = (await wallet.registry().execute('rln_list_assets', {})) as { assets: Array<{ ticker: string; schema: string }> };
90
+ expect(listed.assets.find((a) => a.ticker === 'GENESISB')?.schema).toBe('UDA');
91
+ });
92
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * "Issue an RGB asset" recipe — the hackathon one-liner:
3
+ *
4
+ * "issue 1000 TICKET tokens called Hackathon Ticket" → rln_issue_asset 🔒
5
+ * "mint an NFT called Genesis Badge" → rln_issue_asset 🔒 (UDA)
6
+ * "crea 500 token CAFE chiamati Caffè Club" → rln_issue_asset 🔒
7
+ *
8
+ * No intermediate steps: the plan is a single gated call, so the recipe's job
9
+ * is the slot extraction (name / ticker / supply / schema) and a deterministic
10
+ * readback — exactly the part a 0.6B model gets wrong. Opt-in: register via
11
+ * `Funnel.recipes` on hosts that bind `rln_issue_asset`.
12
+ */
13
+
14
+ import type { Recipe, RecipeContext } from './types.js';
15
+
16
+ const VERB = /\b(issue|mint|create|launch|emetti|conia|crea)\b/i;
17
+ const NOUN = /\b(tokens?|assets?|coins?|nfts?|tickets?|gettoni|badges?)\b/i;
18
+ const NOT_ISSUE = /\b(invoice|fattura|channel|canale|swap|buy|sell|send|pay|compra|vendi|invia|paga)\b/i;
19
+ const NFT = /\b(nfts?|unique|unico|unica)\b/i;
20
+ // Words that look like tickers but are part of the request, not the asset.
21
+ const STOPWORDS = new Set(['RGB', 'NFT', 'NFTS', 'NIA', 'CFA', 'UDA', 'BTC', 'LN', 'AN', 'A']);
22
+
23
+ function parseAmount(t: string): number | undefined {
24
+ const m = t.match(/(?:^|\s)(\d[\d.,]*)\s*([km])?(?=\s|$)/i);
25
+ if (!m) return undefined;
26
+ let n = Number(m[1]!.replace(/,/g, ''));
27
+ if (m[2]) n *= m[2].toLowerCase() === 'k' ? 1_000 : 1_000_000;
28
+ return Number.isFinite(n) && n > 0 ? n : undefined;
29
+ }
30
+
31
+ function parseName(t: string): string | undefined {
32
+ const quoted = t.match(/["“'‘]([^"”'’]{1,40})["”'’]/)?.[1];
33
+ if (quoted) return quoted.trim();
34
+ const m = t.match(/\b(?:called|named|chiamat[oaie])\s+(.+?)(?=\s+(?:with|ticker|con)\b|[,.;]|$)/i);
35
+ return m?.[1]?.trim() || undefined;
36
+ }
37
+
38
+ function parseTicker(t: string, name?: string): string | undefined {
39
+ const explicit = t.match(/\bticker\s+([A-Za-z0-9]{1,8})\b/i)?.[1];
40
+ if (explicit) return explicit.toUpperCase();
41
+ // An all-caps word that isn't part of the name ("1000 TICKET tokens").
42
+ const nameWords = new Set((name ?? '').split(/\s+/));
43
+ for (const w of t.match(/\b[A-Z][A-Z0-9]{1,7}\b/g) ?? []) {
44
+ if (!STOPWORDS.has(w) && !nameWords.has(w)) return w;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** Ticker fallback from the name: "Hackathon Ticket" → "HACKATHO". */
50
+ function tickerFromName(name: string): string {
51
+ return name.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 8);
52
+ }
53
+
54
+ export function extractIssueAsset(text: string): Record<string, unknown> | null {
55
+ const t = text.trim();
56
+ if (!VERB.test(t) || !NOUN.test(t) || NOT_ISSUE.test(t)) return null;
57
+ const schema = NFT.test(t) ? 'UDA' : 'NIA';
58
+ const name = parseName(t);
59
+ const ticker = parseTicker(t, name) ?? (name ? tickerFromName(name) : undefined);
60
+ const amount = schema === 'UDA' ? 1 : parseAmount(t);
61
+ if (!ticker && !name) return null;
62
+ return { name: name ?? ticker, ticker, amount, schema };
63
+ }
64
+
65
+ export const issueAssetRecipe: Recipe = {
66
+ name: 'issue-asset',
67
+ description: 'Issue a new RGB asset (token, ticket, NFT) on the RLN node — one confirmation-gated call.',
68
+ match: (t) => VERB.test(t) && NOUN.test(t) && !NOT_ISSUE.test(t),
69
+ triggers: ['issue', 'mint', 'create token', 'launch token', 'nft'],
70
+ slots: [
71
+ { name: 'name', type: 'string', description: 'Asset name, e.g. "Hackathon Ticket"', required: true },
72
+ { name: 'ticker', type: 'string', description: 'Uppercase ticker, up to 8 chars, e.g. "TICKET"' },
73
+ { name: 'amount', type: 'number', description: 'Total supply in whole units (1 for an NFT)', required: true },
74
+ { name: 'schema', type: 'string', description: 'NIA for a fungible token, UDA for a unique NFT' },
75
+ ],
76
+ extract: extractIssueAsset,
77
+ confident: (s) => !!(s.ticker || s.name) && Number(s.amount) > 0,
78
+ steps: [],
79
+ final: {
80
+ tool: 'rln_issue_asset',
81
+ args: (ctx: RecipeContext) => {
82
+ const name = String(ctx.slots.name ?? ctx.slots.ticker);
83
+ const ticker = String(ctx.slots.ticker ?? tickerFromName(name)).toUpperCase();
84
+ const schema = ctx.slots.schema === 'UDA' ? 'UDA' : 'NIA';
85
+ return { name, ticker, amount: schema === 'UDA' ? 1 : Number(ctx.slots.amount), schema };
86
+ },
87
+ },
88
+ summary: (ctx, final) => {
89
+ // wdk/kaleido MCP return `asset_id` flat; the raw node API nests it under `asset`.
90
+ const r = final as { asset_id?: string; asset?: { asset_id?: string } } | undefined;
91
+ const id = r?.asset_id ?? r?.asset?.asset_id;
92
+ const what = ctx.slots.schema === 'UDA' ? `unique asset ${ctx.slots.ticker ?? ctx.slots.name}` : `${ctx.slots.amount} ${ctx.slots.ticker ?? ctx.slots.name}`;
93
+ return id ? `Issued ${what}. Asset id: ${id}` : `Issued ${what}.`;
94
+ },
95
+ };
@@ -156,6 +156,33 @@ describe('kaleidoswapAtomicRecipe — forceModelExtract (less deterministic slot
156
156
  from_layer: 'BTC_LN', to_layer: 'RGB_LN', to_amount: 1,
157
157
  });
158
158
  });
159
+
160
+ it('stops the chain (status cancelled) when the abort signal fires during extraction', async () => {
161
+ // The stop button aborts the signal mid-inference. extractSlots must NOT
162
+ // paper over an explicit cancel with deterministic slots — the recipe must
163
+ // report cancelled and run NO steps (nothing sent).
164
+ const captured: { name: string; args: any }[] = [];
165
+ const tools = buildStubs(captured);
166
+ const ctrl = new AbortController();
167
+ const abortingProvider: LLMProvider = {
168
+ name: 'aborting',
169
+ runTurn: async () => {
170
+ ctrl.abort(); // simulate the user pressing stop while the model runs
171
+ throw new Error('Inference request "x" was cancelled before it could complete');
172
+ },
173
+ };
174
+
175
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'buy 1 usdt', {
176
+ provider: abortingProvider,
177
+ tools,
178
+ onConfirm: async () => ({ approved: true }),
179
+ signal: ctrl.signal,
180
+ });
181
+
182
+ expect(res.status).toBe('cancelled');
183
+ expect(res.text).toBe('Stopped.');
184
+ expect(captured).toHaveLength(0); // nothing ran — not even the read-only quote
185
+ });
159
186
  });
160
187
 
161
188
  describe('kaleidoswapAtomicRecipe — full chain', () => {