@kaleidorg/mind 0.7.0 → 0.8.1

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 (136) hide show
  1. package/README.md +96 -8
  2. package/dist/context/compress.d.ts.map +1 -1
  3. package/dist/context/compress.js +1 -0
  4. package/dist/context/compress.js.map +1 -1
  5. package/dist/context/rgb-units.d.ts +18 -0
  6. package/dist/context/rgb-units.d.ts.map +1 -0
  7. package/dist/context/rgb-units.js +86 -0
  8. package/dist/context/rgb-units.js.map +1 -0
  9. package/dist/engine.d.ts +59 -1
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +196 -17
  12. package/dist/engine.js.map +1 -1
  13. package/dist/fastpath/fastpath.d.ts +6 -1
  14. package/dist/fastpath/fastpath.d.ts.map +1 -1
  15. package/dist/fastpath/fastpath.js +16 -2
  16. package/dist/fastpath/fastpath.js.map +1 -1
  17. package/dist/fastpath/render.d.ts +2 -0
  18. package/dist/fastpath/render.d.ts.map +1 -0
  19. package/dist/fastpath/render.js +58 -0
  20. package/dist/fastpath/render.js.map +1 -0
  21. package/dist/funnel.d.ts.map +1 -1
  22. package/dist/funnel.js +34 -18
  23. package/dist/funnel.js.map +1 -1
  24. package/dist/guards.d.ts +63 -0
  25. package/dist/guards.d.ts.map +1 -0
  26. package/dist/guards.js +284 -0
  27. package/dist/guards.js.map +1 -0
  28. package/dist/index.d.ts +9 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/kaleidoswap/contract.d.ts +3 -4
  33. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  34. package/dist/kaleidoswap/contract.js +3 -17
  35. package/dist/kaleidoswap/contract.js.map +1 -1
  36. package/dist/providers/openai.d.ts +64 -0
  37. package/dist/providers/openai.d.ts.map +1 -0
  38. package/dist/providers/openai.js +233 -0
  39. package/dist/providers/openai.js.map +1 -0
  40. package/dist/providers/types.d.ts +33 -0
  41. package/dist/providers/types.d.ts.map +1 -1
  42. package/dist/qvac/config.d.ts +1 -1
  43. package/dist/qvac/config.js +1 -1
  44. package/dist/qvac/index.d.ts +1 -0
  45. package/dist/qvac/index.d.ts.map +1 -1
  46. package/dist/qvac/index.js +1 -0
  47. package/dist/qvac/index.js.map +1 -1
  48. package/dist/qvac/models.d.ts +31 -0
  49. package/dist/qvac/models.d.ts.map +1 -0
  50. package/dist/qvac/models.js +80 -0
  51. package/dist/qvac/models.js.map +1 -0
  52. package/dist/qvac/parse.d.ts +11 -0
  53. package/dist/qvac/parse.d.ts.map +1 -1
  54. package/dist/qvac/parse.js +29 -7
  55. package/dist/qvac/parse.js.map +1 -1
  56. package/dist/qvac/provider.d.ts +14 -4
  57. package/dist/qvac/provider.d.ts.map +1 -1
  58. package/dist/qvac/provider.js +33 -8
  59. package/dist/qvac/provider.js.map +1 -1
  60. package/dist/qvac/stream.d.ts +3 -5
  61. package/dist/qvac/stream.d.ts.map +1 -1
  62. package/dist/qvac/stream.js.map +1 -1
  63. package/dist/qvac/tools.d.ts +5 -0
  64. package/dist/qvac/tools.d.ts.map +1 -1
  65. package/dist/qvac/tools.js +10 -0
  66. package/dist/qvac/tools.js.map +1 -1
  67. package/dist/recipe/issue-asset.d.ts.map +1 -1
  68. package/dist/recipe/issue-asset.js +42 -16
  69. package/dist/recipe/issue-asset.js.map +1 -1
  70. package/dist/recipe/runner.d.ts.map +1 -1
  71. package/dist/recipe/runner.js +1 -0
  72. package/dist/recipe/runner.js.map +1 -1
  73. package/dist/recipe/submarine-pay.d.ts +17 -0
  74. package/dist/recipe/submarine-pay.d.ts.map +1 -0
  75. package/dist/recipe/submarine-pay.js +79 -0
  76. package/dist/recipe/submarine-pay.js.map +1 -0
  77. package/dist/skills/registry.js +1 -1
  78. package/dist/skills/select.d.ts +20 -0
  79. package/dist/skills/select.d.ts.map +1 -0
  80. package/dist/skills/select.js +32 -0
  81. package/dist/skills/select.js.map +1 -0
  82. package/dist/submarine/contract.d.ts +42 -0
  83. package/dist/submarine/contract.d.ts.map +1 -0
  84. package/dist/submarine/contract.js +73 -0
  85. package/dist/submarine/contract.js.map +1 -0
  86. package/dist/testing/mock-wallet.d.ts +24 -0
  87. package/dist/testing/mock-wallet.d.ts.map +1 -1
  88. package/dist/testing/mock-wallet.js +75 -6
  89. package/dist/testing/mock-wallet.js.map +1 -1
  90. package/dist/wallet/confirm.d.ts.map +1 -1
  91. package/dist/wallet/confirm.js +27 -9
  92. package/dist/wallet/confirm.js.map +1 -1
  93. package/package.json +6 -1
  94. package/skills/README.md +1 -0
  95. package/skills/kaleido-trading/SKILL.md +22 -28
  96. package/skills/kaleido-trading/references/api.md +3 -3
  97. package/skills/rgb-lightning-node/SKILL.md +32 -12
  98. package/skills/spark-wallet/SKILL.md +1 -0
  99. package/skills/submarine-swaps/SKILL.md +48 -0
  100. package/src/context/compress.ts +1 -0
  101. package/src/context/rgb-units.test.ts +42 -0
  102. package/src/context/rgb-units.ts +89 -0
  103. package/src/engine.test.ts +133 -3
  104. package/src/engine.ts +254 -19
  105. package/src/fastpath/fastpath.ts +22 -2
  106. package/src/fastpath/render.test.ts +39 -0
  107. package/src/fastpath/render.ts +56 -0
  108. package/src/funnel.mind.test.ts +15 -17
  109. package/src/funnel.ts +34 -19
  110. package/src/guards.test.ts +399 -0
  111. package/src/guards.ts +299 -0
  112. package/src/index.ts +37 -1
  113. package/src/kaleidoswap/contract.test.ts +8 -16
  114. package/src/kaleidoswap/contract.ts +4 -32
  115. package/src/providers/openai.test.ts +127 -0
  116. package/src/providers/openai.ts +282 -0
  117. package/src/providers/types.ts +35 -0
  118. package/src/qvac/config.ts +1 -1
  119. package/src/qvac/index.ts +10 -0
  120. package/src/qvac/models.ts +98 -0
  121. package/src/qvac/parse.test.ts +7 -0
  122. package/src/qvac/parse.ts +33 -1
  123. package/src/qvac/provider.test.ts +54 -1
  124. package/src/qvac/provider.ts +49 -13
  125. package/src/qvac/stream.ts +3 -5
  126. package/src/qvac/tools.ts +10 -0
  127. package/src/recipe/issue-asset.test.ts +39 -0
  128. package/src/recipe/issue-asset.ts +41 -13
  129. package/src/recipe/runner.ts +1 -0
  130. package/src/recipe/submarine-pay.test.ts +87 -0
  131. package/src/recipe/submarine-pay.ts +78 -0
  132. package/src/skills/registry.ts +1 -1
  133. package/src/skills/select.ts +41 -0
  134. package/src/submarine/contract.ts +112 -0
  135. package/src/testing/mock-wallet.ts +73 -6
  136. package/src/wallet/confirm.ts +27 -9
@@ -24,6 +24,7 @@ import { toQvacTools } from './tools.js';
24
24
 
25
25
  type CompletionFn = typeof QvacSdk.completion;
26
26
  type CancelFn = typeof QvacSdk.cancel;
27
+ type DeleteCacheFn = typeof QvacSdk.deleteCache;
27
28
 
28
29
  export interface QvacProviderOptions {
29
30
  /** The SDK's `completion` (injected — see module docs). */
@@ -41,12 +42,21 @@ export interface QvacProviderOptions {
41
42
  /** Default max output tokens — caps a turn so it can't ramble. Omit for uncapped. */
42
43
  defaultMaxTokens?: number;
43
44
  /**
44
- * Cap `<think>` reasoning at this many TOKENS (not seconds — tok/s varies, and
45
- * the SDK has no numeric reasoning budget). When a turn's thinking exceeds it,
46
- * the run is cancelled and a short fallback is returned instead of hanging on
47
- * "Thinking…". Omit for unlimited reasoning.
45
+ * Cap `<think>` reasoning at this many TOKENS (not seconds — tok/s varies).
46
+ * Sent as the SDK's `reasoning_budget`, so the model closes its reasoning and
47
+ * answers. If the stream still runs well past it, the run is cancelled and a
48
+ * short fallback is returned instead of hanging on "Thinking…". Omit for
49
+ * unlimited reasoning.
48
50
  */
49
51
  maxThinkingTokens?: number;
52
+ /**
53
+ * Keep each agentic run in a QVAC KV-cache session (`kvCache: sessionKey`),
54
+ * so calls after the first send only the new message instead of re-reading
55
+ * the whole prompt. Needs `deleteCache` to drop the session at the end.
56
+ */
57
+ sessionCache?: boolean;
58
+ /** The SDK's `deleteCache` (injected); used with `sessionCache`. */
59
+ deleteCache?: DeleteCacheFn;
50
60
  /** Stream the model's `<think>` reasoning, when a host wants to surface it. */
51
61
  onThinking?: (token: string) => void;
52
62
  /**
@@ -93,13 +103,23 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
93
103
  // when a value is set so a host that passes neither keeps SDK defaults.
94
104
  const temp = input.temperature ?? options.defaultTemperature;
95
105
  const predict = input.maxTokens ?? options.defaultMaxTokens;
96
- const generationParams =
97
- temp !== undefined || predict !== undefined
98
- ? {
99
- ...(temp !== undefined ? { temp } : {}),
100
- ...(predict !== undefined ? { predict } : {}),
101
- }
102
- : undefined;
106
+ // A thinking budget at or above the output cap never binds: the model can
107
+ // spend the whole turn reasoning and return no answer. Keep half for it.
108
+ const thinkingCap = input.thinking === 'off' ? 0 : (input.maxThinkingTokens ?? options.maxThinkingTokens);
109
+ const maxThinkingTokens =
110
+ thinkingCap !== undefined && predict !== undefined && thinkingCap >= predict
111
+ ? Math.floor(predict / 2)
112
+ : thinkingCap;
113
+ // `tool_choice` is only meaningful with tools; the SDK rejects a named
114
+ // choice that isn't among them.
115
+ const toolChoice = tools && input.toolChoice ? input.toolChoice : undefined;
116
+ const generationParamsRaw = {
117
+ ...(temp !== undefined ? { temp } : {}),
118
+ ...(predict !== undefined ? { predict } : {}),
119
+ ...(maxThinkingTokens !== undefined ? { reasoning_budget: maxThinkingTokens } : {}),
120
+ ...(toolChoice ? { tool_choice: toolChoice } : {}),
121
+ };
122
+ const generationParams = Object.keys(generationParamsRaw).length ? generationParamsRaw : undefined;
103
123
 
104
124
  const run = options.completion({
105
125
  modelId,
@@ -108,6 +128,7 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
108
128
  // Split `<think>` into separate thinkingDelta events so reasoning never
109
129
  // pollutes the visible answer.
110
130
  captureThinking: true,
131
+ ...(options.sessionCache && input.sessionKey ? { kvCache: input.sessionKey } : {}),
111
132
  ...(generationParams ? { generationParams } : {}),
112
133
  ...(tools ? { tools } : {}),
113
134
  } as unknown as Parameters<CompletionFn>[0]);
@@ -129,11 +150,13 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
129
150
  }
130
151
  }
131
152
 
132
- const maxThinkingTokens = input.maxThinkingTokens ?? options.maxThinkingTokens;
133
153
  const result = await consumeRun(run, {
134
154
  onToken: input.onToken,
135
155
  onThinking: input.onThinking ?? options.onThinking,
136
- maxThinkingTokens,
156
+ // Backstop only: the SDK enforces the budget itself, and our count is a
157
+ // char-based estimate, so leave headroom before cancelling.
158
+ maxThinkingTokens:
159
+ maxThinkingTokens === undefined ? undefined : Math.ceil(maxThinkingTokens * 1.25) + 32,
137
160
  // Cancel the in-flight run the moment the thinking budget is blown — the
138
161
  // SDK keeps generating otherwise. Fire-and-forget; `final` then resolves.
139
162
  onThinkingBudgetExceeded: () => {
@@ -175,15 +198,28 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
175
198
  ...(result.stopReason ? { stopReason: result.stopReason } : {}),
176
199
  };
177
200
 
201
+ const incomplete =
202
+ !result.text && result.toolCalls.length === 0 && (result.thinkingBudgetExceeded || !!result.truncated);
178
203
  return {
179
204
  text,
180
205
  rawContent: result.rawContent,
181
206
  toolCalls: result.toolCalls,
207
+ ...(result.toolErrors ? { toolErrors: result.toolErrors } : {}),
182
208
  requestId: result.requestId,
183
209
  inference,
210
+ ...(incomplete ? { incomplete: true } : {}),
184
211
  };
185
212
  },
186
213
 
214
+ async endSession(sessionKey: string): Promise<void> {
215
+ if (!options.sessionCache || !options.deleteCache) return;
216
+ try {
217
+ await options.deleteCache({ kvCacheKey: sessionKey });
218
+ } catch (err) {
219
+ console.warn('[qvac] deleteCache failed:', err);
220
+ }
221
+ },
222
+
187
223
  async cancel(requestId: string): Promise<void> {
188
224
  // The cancel only lands once the server has begun the request; a same-tick
189
225
  // cancel may race the begin and is logged as a no-match by the SDK.
@@ -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
  /**
package/src/qvac/tools.ts CHANGED
@@ -104,3 +104,13 @@ export function toQvacTools(
104
104
  }
105
105
  return tools.map(toQvacTool);
106
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
+ }
@@ -35,6 +35,45 @@ describe('extractIssueAsset (deterministic Tier-0)', () => {
35
35
  });
36
36
  });
37
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
+
38
77
  describe('issue-asset readback', () => {
39
78
  it('fungible: supply + ticker + name', () => {
40
79
  expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Hackathon Ticket', ticker: 'TICKET', amount: 1000 } }))
@@ -13,30 +13,58 @@
13
13
 
14
14
  import type { Recipe, RecipeContext } from './types.js';
15
15
 
16
- const VERB = /\b(issue|mint|create|launch|emetti|conia|crea)\b/i;
16
+ // The request must OPEN with the verb ("issue …", "please mint …", "puoi creare …"),
17
+ // so "I have an issue with my tokens" or "how do I create a token?" stay with the model.
18
+ const VERB = /^(?:(?:please|pls|per favore|can you|could you|puoi|potresti)\s+)?(issue|mint|create|launch|emetti|conia|crea|creare|emettere|coniare)\b/i;
17
19
  const NOUN = /\b(tokens?|assets?|coins?|nfts?|tickets?|gettoni|badges?)\b/i;
18
20
  const NOT_ISSUE = /\b(invoice|fattura|channel|canale|swap|buy|sell|send|pay|compra|vendi|invia|paga)\b/i;
19
21
  const NFT = /\b(nfts?|unique|unico|unica)\b/i;
20
22
  // Words that look like tickers but are part of the request, not the asset.
21
23
  const STOPWORDS = new Set(['RGB', 'NFT', 'NFTS', 'NIA', 'CFA', 'UDA', 'BTC', 'LN', 'AN', 'A']);
22
24
 
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;
25
+ // "1000", "1,000", "1.000" (it), "1.5", "1,5" (it), optionally followed by k/m.
26
+ const NUM = String.raw`(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*([km])?`;
27
+
28
+ function toNumber(digits: string, suffix?: string): number | undefined {
29
+ // Groups of exactly three digits are thousands separators in both "1,000" and "1.000";
30
+ // a single separator followed by another digit count is a decimal point.
31
+ const n = /^\d{1,3}(?:[.,]\d{3})+$/.test(digits)
32
+ ? Number(digits.replace(/[.,]/g, ''))
33
+ : Number(digits.replace(',', '.'));
34
+ const scaled = suffix ? n * (suffix.toLowerCase() === 'k' ? 1_000 : 1_000_000) : n;
35
+ return Number.isFinite(scaled) && scaled > 0 ? scaled : undefined;
29
36
  }
30
37
 
38
+ /**
39
+ * The supply: an explicit "supply 300" / "1m supply" wins, then a number right before the token
40
+ * noun / ticker ("1000 TICKET tokens", "500 token"). Numbers inside the asset name
41
+ * ("Web3 Summit 2026") are never the supply.
42
+ */
43
+ function parseAmount(t: string, name?: string): number | undefined {
44
+ const rest = name ? t.replace(name, ' ') : t;
45
+ const labelled = rest.match(new RegExp(String.raw`\b(?:supply|amount|quantit[àa]|totale)\s*(?:of|di|[:=])?\s*` + NUM, 'i'));
46
+ if (labelled) return toNumber(labelled[1]!, labelled[2]);
47
+ const trailing = rest.match(new RegExp(String.raw`(?:^|\s)` + NUM + String.raw`\s+(?:supply|di supply)\b`, 'i'));
48
+ if (trailing) return toNumber(trailing[1]!, trailing[2]);
49
+ const beforeNoun = rest.match(new RegExp(String.raw`(?:^|\s)` + NUM + String.raw`\s+(?:[A-Z][A-Z0-9]{1,7}\s+)?(?:tokens?|coins?|tickets?|gettoni|badges?|assets?)\b`, 'i'));
50
+ if (beforeNoun) return toNumber(beforeNoun[1]!, beforeNoun[2]);
51
+ return undefined;
52
+ }
53
+
54
+ const unquote = (s: string) => s.replace(/^["“'‘]+|["”'’]+$/g, '').trim();
55
+
31
56
  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;
57
+ // "called Joe's Pizza with ticker 'JOE'" → Joe's Pizza (apostrophes inside a name are fine).
58
+ const m = t.match(/\b(?:called|named|chiamat[oaie])\s+(.+?)(?=\s+(?:with|ticker|con|supply)\b|[,;]|\.(?:\s|$)|$)/i);
59
+ if (m?.[1]) return unquote(m[1]) || undefined;
60
+ // Otherwise only a properly paired quote: double quotes, or single quotes that open
61
+ // after a space and close before a space/punctuation (so "Joe's" never opens one).
62
+ const quoted = t.match(/["“]([^"”]{1,40})["”]/)?.[1] ?? t.match(/(?:^|\s)['‘]([^'’]{1,40})['’](?=\s|$|[,.;!?])/)?.[1];
63
+ return quoted?.trim() || undefined;
36
64
  }
37
65
 
38
66
  function parseTicker(t: string, name?: string): string | undefined {
39
- const explicit = t.match(/\bticker\s+([A-Za-z0-9]{1,8})\b/i)?.[1];
67
+ const explicit = t.match(/\bticker\s*[:=]?\s*["'“‘]?([A-Za-z0-9]{1,8})\b/i)?.[1];
40
68
  if (explicit) return explicit.toUpperCase();
41
69
  // An all-caps word that isn't part of the name ("1000 TICKET tokens").
42
70
  const nameWords = new Set((name ?? '').split(/\s+/));
@@ -57,7 +85,7 @@ export function extractIssueAsset(text: string): Record<string, unknown> | null
57
85
  const schema = NFT.test(t) ? 'UDA' : 'NIA';
58
86
  const name = parseName(t);
59
87
  const ticker = parseTicker(t, name) ?? (name ? tickerFromName(name) : undefined);
60
- const amount = schema === 'UDA' ? 1 : parseAmount(t);
88
+ const amount = schema === 'UDA' ? 1 : parseAmount(t, name);
61
89
  if (!ticker && !name) return null;
62
90
  return { name: name ?? ticker, ticker, amount, schema };
63
91
  }
@@ -111,6 +111,7 @@ export async function extractSlots(
111
111
  system,
112
112
  messages: [{ role: 'user', content: text }],
113
113
  tools: [extractTool],
114
+ toolChoice: EXTRACT_TOOL,
114
115
  signal,
115
116
  });
116
117
  } catch (err) {
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { extractSubmarinePay, submarinePayRecipe } from './submarine-pay.js';
3
+ import { runRecipe } from './runner.js';
4
+ import { Funnel } from '../funnel.js';
5
+ import { paymentsRecipe } from './payments.js';
6
+ import { MockWallet, scriptedProvider } from '../testing/index.js';
7
+ import { SUBMARINE_TOOLS, isSubmarineSpendTool, formatSubmarineAmount } from '../submarine/contract.js';
8
+
9
+ // 10u = 1,000 sats on signet.
10
+ const INVOICE = 'lntbs10u1pnmockinvoice0qqqqqqqqqqqqqqqqqqqqqqqqq';
11
+
12
+ describe('submarine contract', () => {
13
+ it('only fund moves money, and it takes nothing but the swap id', () => {
14
+ expect(SUBMARINE_TOOLS.map((t) => t.name)).toEqual([
15
+ 'kaleidoswap_submarine_pairs', 'kaleidoswap_submarine_create', 'kaleidoswap_submarine_fund', 'kaleidoswap_submarine_status',
16
+ ]);
17
+ expect(SUBMARINE_TOOLS.filter((t) => t.requiresConfirmation).map((t) => t.name)).toEqual(['kaleidoswap_submarine_fund']);
18
+ expect(isSubmarineSpendTool('kaleidoswap_submarine_create')).toBe(false);
19
+ const fund = SUBMARINE_TOOLS.find((t) => t.name === 'kaleidoswap_submarine_fund')!;
20
+ expect(Object.keys((fund.parameters as any).properties)).toEqual(['swap_id']);
21
+ });
22
+ it('formats source amounts for the readback', () => {
23
+ expect(formatSubmarineAmount('L-USDT', '87500000')).toBe('0.875 L-USDT');
24
+ expect(formatSubmarineAmount('L-BTC', 1005)).toBe('1,005 sats of L-BTC');
25
+ });
26
+ });
27
+
28
+ describe('extractSubmarinePay', () => {
29
+ it('needs an invoice and an explicit Liquid asset', () => {
30
+ expect(extractSubmarinePay(`pay ${INVOICE} with L-USDT`)).toEqual({ invoice: INVOICE, from_asset: 'L-USDT' });
31
+ expect(extractSubmarinePay(`paga ${INVOICE} con USDT su Liquid`)).toEqual({ invoice: INVOICE, from_asset: 'L-USDT' });
32
+ expect(extractSubmarinePay(`pay ${INVOICE} using liquid bitcoin`)).toEqual({ invoice: INVOICE, from_asset: 'L-BTC' });
33
+ // Bare USDT is RGB USDT — not this recipe.
34
+ expect(extractSubmarinePay(`pay ${INVOICE} with USDT`)).toBeNull();
35
+ expect(extractSubmarinePay('pay bob 10 L-USDT')).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe('runRecipe — submarine pay against the mock /v2 maker', () => {
40
+ it('creates, confirms once with the maker amount, then funds', async () => {
41
+ const wallet = new MockWallet({ priceUsd: 80_000 });
42
+ const onConfirm = vi.fn(async () => ({ approved: true }));
43
+ const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, { provider: scriptedProvider(), tools: wallet.registry(), onConfirm });
44
+ expect(res.status).toBe('done');
45
+ expect(res.inferences).toBe(0);
46
+ expect(onConfirm).toHaveBeenCalledOnce();
47
+ // 1,000 sats + 0.5% at $80k = 0.804 USD → 80,400,000 units of L-USDT (8 dp).
48
+ expect((onConfirm.mock.calls[0] as any[])[0].summary).toBe(
49
+ 'Pay Lightning invoice lntbs10u1p…qqqqqq with 0.804 L-USDT (fees included) through a KaleidoSwap submarine swap. Confirm?');
50
+ expect(wallet.liquid['L-USDT']).toBe(100 * 1e8 - 80_400_000);
51
+ expect(wallet.sends.map((s) => s.tool)).toEqual(['kaleidoswap_submarine_fund']);
52
+ expect(res.text).toMatch(/^Locked 0\.804 L-USDT in swap sub1/);
53
+ });
54
+
55
+ it('declining funds nothing, but the swap was opened (no money moved)', async () => {
56
+ const wallet = new MockWallet();
57
+ const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, {
58
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: false }),
59
+ });
60
+ expect(res.status).toBe('cancelled');
61
+ expect(wallet.sends).toEqual([]);
62
+ expect(wallet.submarineSwaps.sub1?.funded).toBe(false);
63
+ });
64
+
65
+ it('surfaces insufficient Liquid balance without locking anything', async () => {
66
+ const wallet = new MockWallet({ liquid: { 'L-USDT': 1000, 'L-BTC': 0 } });
67
+ const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, {
68
+ provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: true }),
69
+ });
70
+ expect(res.status).toBe('error');
71
+ expect(res.error ?? res.text).toMatch(/Insufficient L-USDT/);
72
+ expect(wallet.sends).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe('Funnel — submarine-pay ahead of payments', () => {
77
+ it('routes "pay <invoice> with L-USDT" to the submarine recipe, plain invoices elsewhere', async () => {
78
+ const wallet = new MockWallet();
79
+ const funnel = new Funnel({ provider: scriptedProvider(), tools: wallet.registry(), recipes: [submarinePayRecipe, paymentsRecipe] });
80
+ const out = await funnel.runTurn(`pay ${INVOICE} with L-USDT`, { onConfirm: async () => ({ approved: true }) });
81
+ expect(out.tier).toBe('recipe');
82
+ expect(out.route).toBe('submarine-pay');
83
+ const status = await wallet.registry().execute('kaleidoswap_submarine_status', { swap_id: 'sub1' }) as { status: string };
84
+ expect(status.status).toBe('transaction.claimed');
85
+ expect(submarinePayRecipe.match!(`pay ${INVOICE}`)).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * "Pay this Lightning invoice with my Liquid USDT" — a submarine swap on the
3
+ * KaleidoSwap /v2 maker:
4
+ *
5
+ * "pay lntbs1… with L-USDT" → kaleidoswap_submarine_create → 🔒 kaleidoswap_submarine_fund
6
+ * "paga lntbs1… con USDT su Liquid" → same
7
+ *
8
+ * One confirmation, shown after `create` so the user sees the exact amount the
9
+ * maker asked for (fees included) before anything is locked. Keys never reach
10
+ * the model; `fund` takes only the swap id. Opt-in: register via
11
+ * `Funnel.recipes` BEFORE the generic payments recipe on hosts that bind the
12
+ * submarine tools.
13
+ */
14
+
15
+ import type { Recipe, RecipeContext } from './types.js';
16
+ import { formatSubmarineAmount } from '../submarine/contract.js';
17
+
18
+ const INVOICE = /\b(ln(?:bcrt|tbs|bc|tb)[0-9a-z]{20,})\b/i;
19
+ // Only an explicit Liquid asset: a bare "USDT" is RGB USDT (atomic recipe), not L-USDT.
20
+ const L_USDT = /\b(l-?usdt|usdt[- ]?l|liquid[- ]usdt|usdt (?:on|su|in) liquid|usdt liquid)\b/i;
21
+ const L_BTC = /\b(l-?btc|liquid[- ]btc|liquid bitcoin|btc (?:on|su|in) liquid|bitcoin (?:on|su|in) liquid)\b/i;
22
+
23
+ /** Tool results may arrive as JSON text (MCP) or objects (in-process). */
24
+ function obj(v: unknown): Record<string, unknown> {
25
+ if (typeof v === 'string') {
26
+ try { return JSON.parse(v) as Record<string, unknown>; } catch { return {}; }
27
+ }
28
+ return (v ?? {}) as Record<string, unknown>;
29
+ }
30
+
31
+ function shortInvoice(inv: string): string {
32
+ return `${inv.slice(0, 10)}…${inv.slice(-6)}`;
33
+ }
34
+
35
+ export function extractSubmarinePay(text: string): Record<string, unknown> | null {
36
+ const invoice = text.match(INVOICE)?.[1];
37
+ if (!invoice) return null;
38
+ const from_asset = L_USDT.test(text) ? 'L-USDT' : L_BTC.test(text) ? 'L-BTC' : undefined;
39
+ if (!from_asset) return null;
40
+ return { invoice, from_asset };
41
+ }
42
+
43
+ export const submarinePayRecipe: Recipe = {
44
+ name: 'submarine-pay',
45
+ description: 'Pay a Lightning invoice from Liquid funds (L-USDT or L-BTC) via a KaleidoSwap submarine swap — one confirmation.',
46
+ match: (t) => INVOICE.test(t) && (L_USDT.test(t) || L_BTC.test(t)),
47
+ triggers: ['l-usdt', 'liquid usdt', 'l-btc', 'submarine'],
48
+ slots: [
49
+ { name: 'invoice', type: 'string', description: 'BOLT11 Lightning invoice to pay', required: true },
50
+ { name: 'from_asset', type: 'string', description: 'L-USDT or L-BTC', required: true },
51
+ ],
52
+ extract: extractSubmarinePay,
53
+ confident: (s) => typeof s.invoice === 'string' && (s.from_asset === 'L-USDT' || s.from_asset === 'L-BTC'),
54
+ steps: [
55
+ {
56
+ tool: 'kaleidoswap_submarine_create',
57
+ as: 'swap',
58
+ args: (ctx) => ({ invoice: ctx.slots.invoice, from_asset: ctx.slots.from_asset }),
59
+ },
60
+ ],
61
+ confirm: (ctx: RecipeContext) => {
62
+ const swap = obj(ctx.results.swap);
63
+ const asset = String(swap.from_asset ?? ctx.slots.from_asset);
64
+ return `Pay Lightning invoice ${shortInvoice(String(ctx.slots.invoice))} with ${formatSubmarineAmount(asset, swap.expected_amount)} (fees included) through a KaleidoSwap submarine swap. Confirm?`;
65
+ },
66
+ final: {
67
+ tool: 'kaleidoswap_submarine_fund',
68
+ args: (ctx) => ({ swap_id: obj(ctx.results.swap).swap_id }),
69
+ },
70
+ summary: (ctx, final) => {
71
+ const swap = obj(ctx.results.swap);
72
+ const funded = obj(final);
73
+ const asset = String(swap.from_asset ?? ctx.slots.from_asset);
74
+ return `Locked ${formatSubmarineAmount(asset, swap.expected_amount)} in swap ${String(swap.swap_id)}` +
75
+ (funded.txid ? ` (tx ${String(funded.txid).slice(0, 12)}…)` : '') +
76
+ '. The maker pays the invoice once the lockup is seen — ask me for the swap status to follow it.';
77
+ },
78
+ };
@@ -20,7 +20,7 @@ export const READ_REFERENCE_TOOL = 'read_skill_reference';
20
20
  * ---
21
21
  * name: portfolio-manager
22
22
  * description: Rebalance BTC/USDT/XAUT to target allocations.
23
- * tools: get_balance, kaleidoswap_get_quote, kaleidoswap_place_order
23
+ * tools: get_balance, kaleidoswap_get_quote, kaleidoswap_atomic_init
24
24
  * triggers: rebalance, allocation, portfolio
25
25
  * ---
26
26
  * <instructions…>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tool-aware skill selection: pick a skill that can actually act with the tools
3
+ * a host has, before any inference runs.
4
+ */
5
+
6
+ import type { ToolDef } from '../types.js';
7
+ import type { Skill } from './types.js';
8
+ import { READ_REFERENCE_TOOL, SkillRegistry } from './registry.js';
9
+
10
+ /**
11
+ * Whether a skill can act given the live tool names. A `requires-tools`
12
+ * frontmatter list must be fully live; otherwise at least one of the skill's
13
+ * scoped tools must be.
14
+ */
15
+ export function skillAvailable(skill: Skill, present: ReadonlySet<string>): boolean {
16
+ const required = skill.metadata?.['requires-tools'];
17
+ if (required) {
18
+ return required.split(',').map((t) => t.trim()).filter(Boolean).every((t) => present.has(t));
19
+ }
20
+ if (!skill.tools?.length) return true;
21
+ return skill.tools.some((t) => t !== READ_REFERENCE_TOOL && present.has(t));
22
+ }
23
+
24
+ /**
25
+ * Select the best skill for `query` among those that can act with `liveTools`.
26
+ * Skills whose `requires-tools` are missing are never picked; a skill with zero
27
+ * live tools is only a last resort.
28
+ */
29
+ export function selectAvailableSkill(
30
+ skills: SkillRegistry | readonly Skill[],
31
+ query: string,
32
+ liveTools: Iterable<string | Pick<ToolDef, 'name'>>,
33
+ ): Skill | null {
34
+ const list = skills instanceof SkillRegistry ? skills.list() : [...skills];
35
+ const present = new Set([...liveTools].map((t) => (typeof t === 'string' ? t : t.name)));
36
+ const usable = list.filter((s) => skillAvailable(s, present));
37
+ return (
38
+ new SkillRegistry(usable).select(query) ??
39
+ new SkillRegistry(list.filter((s) => !s.metadata?.['requires-tools'] && !usable.includes(s))).select(query)
40
+ );
41
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Canonical submarine-swap tool contract — pay a Lightning invoice from Liquid
3
+ * funds through the KaleidoSwap Boltz /v2 maker (kaleidoswap-maker-rs).
4
+ *
5
+ * The names match kaleido-mcp, which implements them over @kaleidorg/swap-sdk.
6
+ * Hosts with the SDK in-process (desktop sidecar, React Native) bind the same
7
+ * names with `bindSubmarineTools`.
8
+ *
9
+ * Keys and preimages never cross this contract: the host derives the refund key
10
+ * from its wallet mnemonic and persists the swap. `kaleidoswap_submarine_fund`
11
+ * — the only spend — takes nothing but the swap id, so the model cannot alter
12
+ * the amount, asset or lockup address it pays.
13
+ *
14
+ * Pure data — no deps, no fetch, RN-safe.
15
+ */
16
+
17
+ import type { ToolDef } from '../types.js';
18
+ import { InProcessToolSource } from '../tools/in-process.js';
19
+ import type { InProcessTool } from '../tools/in-process.js';
20
+
21
+ export interface SubmarineToolDef extends ToolDef {
22
+ /** Moves funds → confirmation-gated. */
23
+ spend?: boolean;
24
+ }
25
+
26
+ type Props = Record<string, { type: string; description?: string; enum?: string[] }>;
27
+
28
+ function t(name: string, description: string, properties: Props = {}, required: string[] = [], spend = false): SubmarineToolDef {
29
+ return { name, description, spend, requiresConfirmation: spend, parameters: { type: 'object', properties, required } };
30
+ }
31
+
32
+ /** Assets a submarine swap can be paid from, as the maker names them. */
33
+ export const SUBMARINE_FROM_ASSETS = ['L-USDT', 'L-BTC'] as const;
34
+ export type SubmarineFromAsset = typeof SUBMARINE_FROM_ASSETS[number];
35
+
36
+ /** Smallest-unit decimals of each source asset (L-USDT: 8, L-BTC: sats). */
37
+ export const SUBMARINE_ASSET_DECIMALS: Record<SubmarineFromAsset, number> = { 'L-USDT': 8, 'L-BTC': 8 };
38
+
39
+ const swapId = { type: 'string', description: 'Swap id returned by kaleidoswap_submarine_create' } as const;
40
+
41
+ export const SUBMARINE_TOOLS: SubmarineToolDef[] = [
42
+ t('kaleidoswap_submarine_pairs',
43
+ 'List what can pay a Lightning invoice through a submarine swap (e.g. L-USDT on Liquid → Lightning BTC): rate, min/max, fees. No args.'),
44
+
45
+ t('kaleidoswap_submarine_create',
46
+ 'Open a submarine swap that pays a Lightning (BOLT11) invoice from Liquid funds. No funds move: returns swap_id and expected_amount (source asset smallest unit). Then confirm with the user and call kaleidoswap_submarine_fund.',
47
+ {
48
+ invoice: { type: 'string', description: 'BOLT11 Lightning invoice to pay (must carry an amount)' },
49
+ from_asset: { type: 'string', enum: [...SUBMARINE_FROM_ASSETS], description: 'Asset to pay with (default L-USDT)' },
50
+ },
51
+ ['invoice']),
52
+
53
+ t('kaleidoswap_submarine_fund',
54
+ 'SPEND: lock the funds for a swap from kaleidoswap_submarine_create. Takes only the swap id — amount, asset and address come from the stored swap. The maker then pays the invoice.',
55
+ { swap_id: swapId },
56
+ ['swap_id'],
57
+ /* spend */ true),
58
+
59
+ t('kaleidoswap_submarine_status',
60
+ 'Status of a submarine swap. "transaction.claimed" = invoice paid, done. A failed funded swap needs a refund.',
61
+ { swap_id: swapId },
62
+ ['swap_id']),
63
+ ];
64
+
65
+ export const SUBMARINE_SPEND_TOOLS: Set<string> = new Set(SUBMARINE_TOOLS.filter((d) => d.spend).map((d) => d.name));
66
+
67
+ export function isSubmarineSpendTool(name: string): boolean {
68
+ return SUBMARINE_SPEND_TOOLS.has(name);
69
+ }
70
+
71
+ export function getSubmarineTool(name: string): SubmarineToolDef | undefined {
72
+ return SUBMARINE_TOOLS.find((d) => d.name === name);
73
+ }
74
+
75
+ /** Format a smallest-unit amount of a source asset for display ("52.3 L-USDT", "1,200 sats"). */
76
+ export function formatSubmarineAmount(asset: string, smallestUnits: unknown): string {
77
+ const n = Number(smallestUnits);
78
+ if (!Number.isFinite(n)) return `${String(smallestUnits)} ${asset}`;
79
+ if (asset === 'L-BTC') return `${n.toLocaleString('en-US')} sats of L-BTC`;
80
+ const decimals = SUBMARINE_ASSET_DECIMALS[asset as SubmarineFromAsset] ?? 0;
81
+ const value = n / 10 ** decimals;
82
+ return `${value.toLocaleString('en-US', { maximumFractionDigits: decimals })} ${asset}`;
83
+ }
84
+
85
+ export type SubmarineHandler = (args: Record<string, unknown>) => Promise<unknown>;
86
+
87
+ export interface BindSubmarineOptions {
88
+ /** Skip tools without a handler instead of throwing (default false). */
89
+ allowMissing?: boolean;
90
+ /** ToolSource id for the registry (default 'submarine'). */
91
+ id?: string;
92
+ }
93
+
94
+ /** Bind the submarine contract to in-process handlers → an InProcessToolSource. */
95
+ export function bindSubmarineTools(handlers: Record<string, SubmarineHandler>, opts: BindSubmarineOptions = {}): InProcessToolSource {
96
+ const bound: InProcessTool[] = [];
97
+ for (const def of SUBMARINE_TOOLS) {
98
+ const handler = handlers[def.name];
99
+ if (!handler) {
100
+ if (opts.allowMissing) continue;
101
+ throw new Error(`bindSubmarineTools: no handler for "${def.name}"`);
102
+ }
103
+ bound.push({
104
+ name: def.name,
105
+ description: def.description,
106
+ parameters: def.parameters,
107
+ requiresConfirmation: def.requiresConfirmation,
108
+ handler,
109
+ });
110
+ }
111
+ return new InProcessToolSource(opts.id ?? 'submarine', bound);
112
+ }