@kaleidorg/mind 0.7.0 → 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 (125) 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 +53 -1
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +179 -17
  12. package/dist/engine.js.map +1 -1
  13. package/dist/funnel.d.ts.map +1 -1
  14. package/dist/funnel.js +23 -4
  15. package/dist/funnel.js.map +1 -1
  16. package/dist/guards.d.ts +63 -0
  17. package/dist/guards.d.ts.map +1 -0
  18. package/dist/guards.js +284 -0
  19. package/dist/guards.js.map +1 -0
  20. package/dist/index.d.ts +9 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/kaleidoswap/contract.d.ts +3 -4
  25. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  26. package/dist/kaleidoswap/contract.js +3 -17
  27. package/dist/kaleidoswap/contract.js.map +1 -1
  28. package/dist/providers/openai.d.ts +64 -0
  29. package/dist/providers/openai.d.ts.map +1 -0
  30. package/dist/providers/openai.js +233 -0
  31. package/dist/providers/openai.js.map +1 -0
  32. package/dist/providers/types.d.ts +20 -0
  33. package/dist/providers/types.d.ts.map +1 -1
  34. package/dist/qvac/config.d.ts +1 -1
  35. package/dist/qvac/config.js +1 -1
  36. package/dist/qvac/index.d.ts +1 -0
  37. package/dist/qvac/index.d.ts.map +1 -1
  38. package/dist/qvac/index.js +1 -0
  39. package/dist/qvac/index.js.map +1 -1
  40. package/dist/qvac/models.d.ts +31 -0
  41. package/dist/qvac/models.d.ts.map +1 -0
  42. package/dist/qvac/models.js +80 -0
  43. package/dist/qvac/models.js.map +1 -0
  44. package/dist/qvac/parse.d.ts +11 -0
  45. package/dist/qvac/parse.d.ts.map +1 -1
  46. package/dist/qvac/parse.js +29 -7
  47. package/dist/qvac/parse.js.map +1 -1
  48. package/dist/qvac/provider.d.ts +5 -4
  49. package/dist/qvac/provider.d.ts.map +1 -1
  50. package/dist/qvac/provider.js +22 -8
  51. package/dist/qvac/provider.js.map +1 -1
  52. package/dist/qvac/stream.d.ts +3 -5
  53. package/dist/qvac/stream.d.ts.map +1 -1
  54. package/dist/qvac/stream.js.map +1 -1
  55. package/dist/qvac/tools.d.ts +5 -0
  56. package/dist/qvac/tools.d.ts.map +1 -1
  57. package/dist/qvac/tools.js +10 -0
  58. package/dist/qvac/tools.js.map +1 -1
  59. package/dist/recipe/issue-asset.d.ts.map +1 -1
  60. package/dist/recipe/issue-asset.js +42 -16
  61. package/dist/recipe/issue-asset.js.map +1 -1
  62. package/dist/recipe/runner.d.ts.map +1 -1
  63. package/dist/recipe/runner.js +1 -0
  64. package/dist/recipe/runner.js.map +1 -1
  65. package/dist/recipe/submarine-pay.d.ts +17 -0
  66. package/dist/recipe/submarine-pay.d.ts.map +1 -0
  67. package/dist/recipe/submarine-pay.js +79 -0
  68. package/dist/recipe/submarine-pay.js.map +1 -0
  69. package/dist/skills/registry.js +1 -1
  70. package/dist/skills/select.d.ts +20 -0
  71. package/dist/skills/select.d.ts.map +1 -0
  72. package/dist/skills/select.js +32 -0
  73. package/dist/skills/select.js.map +1 -0
  74. package/dist/submarine/contract.d.ts +42 -0
  75. package/dist/submarine/contract.d.ts.map +1 -0
  76. package/dist/submarine/contract.js +73 -0
  77. package/dist/submarine/contract.js.map +1 -0
  78. package/dist/testing/mock-wallet.d.ts +24 -0
  79. package/dist/testing/mock-wallet.d.ts.map +1 -1
  80. package/dist/testing/mock-wallet.js +75 -6
  81. package/dist/testing/mock-wallet.js.map +1 -1
  82. package/dist/wallet/confirm.d.ts.map +1 -1
  83. package/dist/wallet/confirm.js +27 -9
  84. package/dist/wallet/confirm.js.map +1 -1
  85. package/package.json +6 -1
  86. package/skills/README.md +1 -0
  87. package/skills/kaleido-trading/SKILL.md +22 -28
  88. package/skills/kaleido-trading/references/api.md +3 -3
  89. package/skills/rgb-lightning-node/SKILL.md +32 -12
  90. package/skills/spark-wallet/SKILL.md +1 -0
  91. package/skills/submarine-swaps/SKILL.md +48 -0
  92. package/src/context/compress.ts +1 -0
  93. package/src/context/rgb-units.test.ts +42 -0
  94. package/src/context/rgb-units.ts +89 -0
  95. package/src/engine.test.ts +94 -3
  96. package/src/engine.ts +234 -19
  97. package/src/funnel.mind.test.ts +4 -2
  98. package/src/funnel.ts +23 -4
  99. package/src/guards.test.ts +399 -0
  100. package/src/guards.ts +299 -0
  101. package/src/index.ts +37 -1
  102. package/src/kaleidoswap/contract.test.ts +8 -16
  103. package/src/kaleidoswap/contract.ts +4 -32
  104. package/src/providers/openai.test.ts +127 -0
  105. package/src/providers/openai.ts +282 -0
  106. package/src/providers/types.ts +22 -0
  107. package/src/qvac/config.ts +1 -1
  108. package/src/qvac/index.ts +10 -0
  109. package/src/qvac/models.ts +98 -0
  110. package/src/qvac/parse.test.ts +7 -0
  111. package/src/qvac/parse.ts +33 -1
  112. package/src/qvac/provider.test.ts +34 -1
  113. package/src/qvac/provider.ts +30 -13
  114. package/src/qvac/stream.ts +3 -5
  115. package/src/qvac/tools.ts +10 -0
  116. package/src/recipe/issue-asset.test.ts +39 -0
  117. package/src/recipe/issue-asset.ts +41 -13
  118. package/src/recipe/runner.ts +1 -0
  119. package/src/recipe/submarine-pay.test.ts +87 -0
  120. package/src/recipe/submarine-pay.ts +78 -0
  121. package/src/skills/registry.ts +1 -1
  122. package/src/skills/select.ts +41 -0
  123. package/src/submarine/contract.ts +112 -0
  124. package/src/testing/mock-wallet.ts +73 -6
  125. package/src/wallet/confirm.ts +27 -9
@@ -5,7 +5,7 @@ tools: rln_get_node_info, rln_get_balances, rln_list_channels, rln_list_assets,
5
5
  triggers: node, nodeinfo, pubkey, peer, channels, channel capacity, list channels, open channel, close channel, inbound, capacity, asset balance, whitelist, taker, swapstring, swaps, payments, invoice, receive, send asset, send rgb, on-chain address, deposit, rgb invoice, ln invoice, issue, mint, new token, nft, utxos, transfers
6
6
  metadata:
7
7
  author: kaleidoswap
8
- version: "0.3.2"
8
+ version: "0.3.3"
9
9
  ---
10
10
 
11
11
  # RGB Lightning Node (taker-side)
@@ -70,14 +70,26 @@ ASYNCHRONOUSLY (seconds to minutes after payment). If the new channel isn't
70
70
  listed yet, say it's still opening and suggest checking again — don't claim
71
71
  failure.
72
72
 
73
- ### `rln_list_assets` — no args
74
- Lists RGB assets known to the node with per-asset balances (settled, future,
75
- spendable, offchain_outbound, offchain_inbound). Use for "what assets do I
76
- hold / what's my USDT balance".
73
+ ### `rln_list_assets` — { schemas? }
74
+ Lists RGB assets known to the node: `asset_id`, `ticker`, `name`, `precision`
75
+ (in-app wallets also return balances). `schemas` is an optional filter — an
76
+ array of `"Nia"`, `"Uda"`, `"Cfa"`; call it with `{}` to list everything.
77
+ Use for "what assets do I hold / what's my USDT balance".
78
+
79
+ Call it **once** per question. An empty list is a real answer: the node holds
80
+ no RGB assets yet — tell the user that (and offer to issue one) instead of
81
+ calling it again.
82
+
83
+ ### Tickers are not asset ids
84
+ `asset_id` arguments take the full RGB id (`rgb:…`), never a ticker like
85
+ `USDT` or `HCK`. When the user names an asset by ticker or name, first call
86
+ `rln_list_assets`, find the entry whose `ticker`/`name` matches, and pass its
87
+ `asset_id`. If nothing matches, say the node has no such asset — do not guess
88
+ an id.
77
89
 
78
90
  ### `rln_get_asset_balance` — { asset_id }
79
- Balance for one RGB asset by id. Use after `rln_list_assets` gave you the id,
80
- or when the user names a specific asset.
91
+ Balance for one RGB asset by id. Resolve a ticker to its `asset_id` with
92
+ `rln_list_assets` first.
81
93
 
82
94
  | Field | Meaning |
83
95
  |---|---|
@@ -105,7 +117,8 @@ the amount.
105
117
  Sends an RGB asset to the recipient encoded in an RGB invoice. Use the argument
106
118
  names of the schema you were given: in-app wallets take `{ asset, amount, to }`
107
119
  (ticker or asset_id, units, the invoice); kaleido-mcp takes
108
- `{ asset_id, amount, recipient_id }`. Never invent a recipient.
120
+ `{ asset_id, amount, recipient_id }`, where `asset_id` is the `rgb:…` id from
121
+ `rln_list_assets`. Never invent a recipient.
109
122
 
110
123
  ### `rln_pay_invoice` — { invoice } — 🔒 confirm-gated
111
124
  Pays a BOLT11 Lightning invoice from the node. Pass the full invoice string.
@@ -144,16 +157,21 @@ Call this **after** `kaleidoswap_atomic_init` and **before**
144
157
  ### `rln_create_ln_invoice` — Lightning invoice for receiving sats
145
158
  Args:
146
159
  - `amount_sats` (optional) — omit for an amountless invoice.
160
+ - `description` (optional, kaleido-mcp) — memo shown to the payer.
147
161
  - `expiry_sec` (default 3600, kaleido-mcp only) — invoice TTL in seconds.
148
162
 
163
+ Reply with the full `invoice` string from the result — never write an invoice
164
+ yourself.
165
+
149
166
  Use when the user wants to **receive** a Lightning payment. Do NOT call inside
150
167
  an atomic swap flow unless the user explicitly asked to invoice someone.
151
168
 
152
169
  ### `rln_create_rgb_invoice` — on-chain RGB receive invoice
153
170
  Args (use the names in your schema):
154
171
  - in-app wallets: `asset` (ticker or asset_id) + `amount`.
155
- - kaleido-mcp: `asset_id` (omit for an any-asset invoice), `amount` (display
156
- units), `duration_seconds` (default 86400).
172
+ - kaleido-mcp: `asset_id` (the `rgb:…` id from `rln_list_assets` — never a
173
+ ticker; omit for an any-asset invoice), `amount` (display units),
174
+ `duration_seconds` (default 86400).
157
175
 
158
176
  Reply with the full `invoice` string; the payer needs all of it.
159
177
 
@@ -176,8 +194,10 @@ defaults (`num` 5, `fee_rate` 1) are fine; set `up_to: true` to only top up to
176
194
  ### `rln_issue_asset` — { name, ticker?, amount?, precision?, schema?, details? } — 🔒 confirm-gated
177
195
  Creates a **new** RGB asset owned by this node. `schema`: `NIA` fungible token
178
196
  (default), `CFA` collectible, `UDA` unique asset / NFT (supply 1).
179
- - `ticker` (uppercase, 1–8 letters/digits) is required for `NIA` and `UDA`.
180
- - `amount` is the total supply in display units, required for `NIA` and `CFA`;
197
+ - `ticker` (uppercase, 1–8 letters/digits, nothing else) is required for `NIA`
198
+ and `UDA`.
199
+ - `amount` is the total supply in display units (a number), required for `NIA`
200
+ and `CFA` — if the user did not say how many, ask before calling;
181
201
  the raw supply is `amount × 10^precision` (`precision` defaults to 0).
182
202
  - `details` is an optional description for `CFA` and `UDA`.
183
203
 
@@ -3,6 +3,7 @@ name: spark-wallet
3
3
  description: "Operate the user's Spark BTC wallet on this device — check Spark balance, get a Spark deposit address, create a Spark Lightning invoice to receive, pay any BOLT11 Lightning invoice with Spark, or send BTC on-chain from Spark. Use this when the user names Spark explicitly OR when paying a Lightning invoice on a phone where Spark is the connected layer. Pairs with the bitrefill skill: a Bitrefill purchase that returns a Lightning invoice is paid with `spark_pay_invoice`."
4
4
  tools: spark_get_balance, spark_get_address, spark_get_onchain_address, spark_create_invoice, spark_pay_invoice, spark_send, get_price, fiat_to_sats, bitrefill_search, bitrefill_get_product, bitrefill_get_balance, bitrefill_create_invoice, bitrefill_get_invoice, bitrefill_get_order
5
5
  triggers: spark, sprak, spakr, spark wallet, pay with spark, send with spark, spark balance, spark address, spark invoice, lightning invoice, pay invoice, bolt11, ln invoice, pay this invoice, on-chain address, onchain address, deposit address, deposit btc, fund spark
6
+ requires-tools: spark_get_balance
6
7
  metadata:
7
8
  author: kaleidoswap
8
9
  version: "1.0.0"
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: submarine-swaps
3
+ description: "Pay a Lightning invoice from Liquid funds (L-USDT or L-BTC) through a KaleidoSwap submarine swap on the /v2 maker, and follow its status. Triggers when the user wants to pay a Lightning invoice with Liquid USDT / L-USDT / L-BTC, asks what Liquid assets can pay Lightning, or asks about a submarine swap."
4
+ tools: kaleidoswap_submarine_pairs, kaleidoswap_submarine_create, kaleidoswap_submarine_fund, kaleidoswap_submarine_status
5
+ triggers: l-usdt, liquid usdt, usdt on liquid, l-btc, liquid bitcoin, submarine, submarine swap, pay invoice with liquid, swap status
6
+ metadata:
7
+ author: kaleidoswap
8
+ version: "0.1.0"
9
+ ---
10
+
11
+ # Submarine swaps — pay Lightning from Liquid
12
+
13
+ A submarine swap pays a Lightning (BOLT11) invoice with funds the user locks on
14
+ Liquid. The KaleidoSwap maker pays the invoice, then claims the lockup. It is
15
+ atomic: if the maker can't pay, the lockup is refunded to the user.
16
+
17
+ ## Critical rules
18
+
19
+ - Every amount, fee and status in your reply MUST come from a tool result in the
20
+ CURRENT turn. Never guess the amount to lock — `kaleidoswap_submarine_create`
21
+ returns it.
22
+ - `kaleidoswap_submarine_fund` moves money. Call it ONLY after the user has
23
+ confirmed the exact `expected_amount` returned by `kaleidoswap_submarine_create`.
24
+ - `kaleidoswap_submarine_fund` takes only `swap_id`. Never try to pass an amount
25
+ or an address.
26
+ - A bare "USDT" means RGB USDT on the Lightning node, which uses the atomic swap
27
+ tools, not this skill. Use this skill only for Liquid assets (L-USDT, L-BTC).
28
+
29
+ ## Flow
30
+
31
+ 1. `kaleidoswap_submarine_create { invoice, from_asset }` — from_asset is
32
+ `L-USDT` (default) or `L-BTC`. Returns `swap_id`, `expected_amount` (smallest
33
+ unit: L-USDT has 8 decimals, L-BTC is sats) and the fees.
34
+ 2. Tell the user the amount and ask to confirm.
35
+ 3. `kaleidoswap_submarine_fund { swap_id }`.
36
+ 4. `kaleidoswap_submarine_status { swap_id }` — `transaction.claimed` means the
37
+ invoice was paid. `failed: true` on a funded swap means the funds need a refund;
38
+ say so and quote the `refund` field.
39
+
40
+ Use `kaleidoswap_submarine_pairs` when the user asks what can pay a Lightning
41
+ invoice, or for the limits and fees.
42
+
43
+ ## Examples
44
+
45
+ - "pay lntbs10u1p… with L-USDT" →
46
+ `kaleidoswap_submarine_create { invoice: "lntbs10u1p…", from_asset: "L-USDT" }`
47
+ - "did my Liquid payment go through? swap sub1" →
48
+ `kaleidoswap_submarine_status { swap_id: "sub1" }`
@@ -48,6 +48,7 @@ export const DEFAULT_PRESERVE_KEYS: readonly string[] = [
48
48
  'total',
49
49
  'total_sats',
50
50
  'balance',
51
+ 'balance_display',
51
52
  'balance_sat',
52
53
  'address',
53
54
  'invoice',
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { annotateRgbBalances, fixRgbBalanceUnits, formatRgbAmount } from './rgb-units.js';
3
+
4
+ const LIST = {
5
+ assets: [
6
+ { asset_id: 'rgb:usdt', ticker: 'USDT', name: 'Tether USD', precision: 0, balance: { settled: 1000, spendable: 1000 } },
7
+ { asset_id: 'rgb:xaut', ticker: 'XAUT', name: 'Tether Gold', precision: 0, balance: { spendable: 2 } },
8
+ { asset_id: 'rgb:dec', ticker: 'DEC', precision: 6, balance: { spendable: 1_500_000 } },
9
+ ],
10
+ };
11
+
12
+ describe('RGB balance units', () => {
13
+ it('formats raw amounts with the asset precision', () => {
14
+ expect(formatRgbAmount(1_500_000, 6)).toBe('1.5');
15
+ expect(formatRgbAmount(1_000_000, 0)).toBe('1,000,000');
16
+ });
17
+
18
+ it('adds balance_display next to each asset balance without touching the rest', () => {
19
+ const out = annotateRgbBalances(LIST) as typeof LIST & { assets: Array<{ balance_display: Record<string, string> }> };
20
+ expect(out.assets[0]!.balance_display).toEqual({ settled: '1,000 USDT', spendable: '1,000 USDT' });
21
+ expect(out.assets[2]!.balance_display).toEqual({ spendable: '1.5 DEC' });
22
+ expect(out.assets[0]!.balance).toEqual({ settled: 1000, spendable: 1000 });
23
+ expect(annotateRgbBalances({ vanilla: { spendable: 4277 } })).toEqual({ vanilla: { spendable: 4277 } });
24
+ });
25
+
26
+ it('relabels the asset balances the 2B model called satoshis', () => {
27
+ const answer =
28
+ 'You hold two RGB assets:\n1. **USDT** (Tether USD)\n - Balance: 1,000 satoshis (spendable)\n' +
29
+ '2. **XAUT** (Tether Gold)\n - Balance: 2 satoshis (spendable)';
30
+ expect(fixRgbBalanceUnits(answer, [LIST])).toBe(
31
+ 'You hold two RGB assets:\n1. **USDT** (Tether USD)\n - Balance: 1,000 USDT (spendable)\n' +
32
+ '2. **XAUT** (Tether Gold)\n - Balance: 2 XAUT (spendable)',
33
+ );
34
+ expect(fixRgbBalanceUnits('DEC balance: 1,500,000 sats', [LIST])).toBe('DEC balance: 1.5 DEC');
35
+ });
36
+
37
+ it('leaves real sats and unmatched numbers alone', () => {
38
+ const btc = { btc: { vanilla: { spendable: 4277 } } };
39
+ expect(fixRgbBalanceUnits('Your on-chain balance is 4,277 sats.', [btc, LIST])).toBe('Your on-chain balance is 4,277 sats.');
40
+ expect(fixRgbBalanceUnits('USDT channel fee: 300 sats', [LIST])).toBe('USDT channel fee: 300 sats');
41
+ });
42
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RGB asset balances are raw integers in the asset's own unit, scaled by its
3
+ * `precision`. Nothing in the tool result says so, and small models fill the
4
+ * gap with "satoshis" ("USDT — Balance: 1,000 satoshis").
5
+ *
6
+ * `annotateRgbBalances` adds a `balance_display` next to each asset's
7
+ * `balance` before the result reaches the model ("1,000 USDT").
8
+ * `fixRgbBalanceUnits` corrects a final answer that still labels an asset
9
+ * balance as sats: the number must equal one of that asset's balances and the
10
+ * nearest ticker before it must be that asset's.
11
+ */
12
+
13
+ type Obj = Record<string, unknown>;
14
+
15
+ interface RgbAsset {
16
+ ticker: string;
17
+ precision: number;
18
+ amounts: number[];
19
+ }
20
+
21
+ const BALANCE_FIELDS = ['settled', 'spendable', 'future'] as const;
22
+
23
+ const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
24
+
25
+ function asAsset(v: Obj): RgbAsset | null {
26
+ if (typeof v.ticker !== 'string' || !v.ticker || !isObj(v.balance)) return null;
27
+ const amounts = BALANCE_FIELDS.map((k) => (v.balance as Obj)[k]).filter((n): n is number => typeof n === 'number');
28
+ if (!amounts.length) return null;
29
+ const precision = typeof v.precision === 'number' && v.precision > 0 ? v.precision : 0;
30
+ return { ticker: v.ticker, precision, amounts };
31
+ }
32
+
33
+ /** A raw RGB amount in display units, e.g. 1500000 at precision 6 → "1.5". */
34
+ export function formatRgbAmount(raw: number, precision: number): string {
35
+ const value = raw / 10 ** precision;
36
+ return value.toLocaleString('en-US', { maximumFractionDigits: precision });
37
+ }
38
+
39
+ /** Return a copy of `result` where every RGB asset carries `balance_display`. */
40
+ export function annotateRgbBalances(result: unknown): unknown {
41
+ if (Array.isArray(result)) return result.map(annotateRgbBalances);
42
+ if (!isObj(result)) return result;
43
+ const out: Obj = {};
44
+ for (const [k, v] of Object.entries(result)) out[k] = annotateRgbBalances(v);
45
+ const asset = asAsset(result);
46
+ if (asset) {
47
+ const balance = result.balance as Obj;
48
+ const display: Obj = {};
49
+ for (const k of BALANCE_FIELDS) {
50
+ if (typeof balance[k] === 'number') display[k] = `${formatRgbAmount(balance[k] as number, asset.precision)} ${asset.ticker}`;
51
+ }
52
+ out.balance_display = display;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function collectAssets(value: unknown, into: RgbAsset[]): void {
58
+ if (Array.isArray(value)) {
59
+ for (const v of value) collectAssets(v, into);
60
+ } else if (isObj(value)) {
61
+ const asset = asAsset(value);
62
+ if (asset) into.push(asset);
63
+ for (const v of Object.values(value)) collectAssets(v, into);
64
+ }
65
+ }
66
+
67
+ const AMOUNT_IN_SATS = /(\d[\d,]*(?:\.\d+)?)\s*(?:satoshis|sats?)\b/gi;
68
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+
70
+ /** Relabel asset balances that the answer calls sats, using the tool results it was built from. */
71
+ export function fixRgbBalanceUnits(text: string, toolResults: unknown[]): string {
72
+ const assets: RgbAsset[] = [];
73
+ for (const r of toolResults) collectAssets(r, assets);
74
+ if (!assets.length) return text;
75
+ const tickerRe = new RegExp(`\\b(${[...new Set(assets.map((a) => escapeRe(a.ticker)))].join('|')})\\b`, 'g');
76
+
77
+ return text.replace(AMOUNT_IN_SATS, (match: string, num: string, offset: number) => {
78
+ let nearest: string | undefined;
79
+ for (const m of text.slice(Math.max(0, offset - 300), offset).matchAll(tickerRe)) nearest = m[1];
80
+ if (!nearest) return match;
81
+ const value = Number(num.replace(/,/g, ''));
82
+ const asset = assets.find(
83
+ (a) => a.ticker === nearest && a.amounts.some((raw) => raw === value || raw / 10 ** a.precision === value),
84
+ );
85
+ if (!asset) return match;
86
+ const raw = asset.amounts.find((r) => r === value) ?? value * 10 ** asset.precision;
87
+ return `${formatRgbAmount(raw, asset.precision)} ${asset.ticker}`;
88
+ });
89
+ }
@@ -111,8 +111,8 @@ describe('Engine agentic loop', () => {
111
111
  const res = await engine.runAgentic([{ role: 'user', content: 'pay lnbc1' }], { onConfirm });
112
112
 
113
113
  expect(payTool.handler).not.toHaveBeenCalled();
114
- expect(res.toolCalls[0].result).toMatchObject({ declined: true, reason: 'cancelled' });
115
- expect(res.text).toBe('Okay, cancelled.');
114
+ expect(res.toolCalls[0].result).toMatchObject({ status: 'cancelled_by_user', declined_by: 'user', host_reason: 'cancelled' });
115
+ expect(res.text).toBe('Cancelled — you declined: pay invoice. Nothing was sent or changed.');
116
116
  });
117
117
 
118
118
  it('chains multiple tool calls across turns', async () => {
@@ -138,7 +138,9 @@ describe('Engine agentic loop', () => {
138
138
  it('stops at maxTurns if the model never stops calling tools', async () => {
139
139
  const engine = new Engine({
140
140
  provider: scriptedProvider([
141
- { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: {} }] }, // always calls a tool
141
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 1 } }] },
142
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 2 } }] },
143
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: { n: 3 } }] },
142
144
  ]),
143
145
  tools: freshTools(),
144
146
  defaultMaxTurns: 3,
@@ -205,6 +207,95 @@ describe('Engine agentic loop', () => {
205
207
  expect(res.toolCalls[0].result).toMatchObject({ error: 'kaboom' });
206
208
  expect(res.text).toBe('handled the error');
207
209
  });
210
+
211
+ it('sends firstTurnToolChoice on the first call only', async () => {
212
+ const seen: Array<string | undefined> = [];
213
+ const provider: LLMProvider = {
214
+ name: 'rec',
215
+ async runTurn(input) {
216
+ seen.push(input.toolChoice);
217
+ return seen.length === 1
218
+ ? { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] }
219
+ : { text: 'done', rawContent: 'done', toolCalls: [] };
220
+ },
221
+ };
222
+ const engine = new Engine({ provider, tools: freshTools() });
223
+ await engine.runAgentic([{ role: 'user', content: 'send 1000 sats' }], { firstTurnToolChoice: 'required' });
224
+ expect(seen).toEqual(['required', undefined]);
225
+ });
226
+
227
+ it('feeds an unparseable tool call back to the model once, then gives up cleanly', async () => {
228
+ const histories: number[] = [];
229
+ const provider: LLMProvider = {
230
+ name: 'broken',
231
+ async runTurn(input) {
232
+ histories.push(input.messages.length);
233
+ return {
234
+ text: '<tool_call>{"ticker":"HCK',
235
+ rawContent: '<tool_call>{"ticker":"HCK',
236
+ toolCalls: [],
237
+ toolErrors: [{ code: 'PARSE_ERROR', message: 'unterminated string' }],
238
+ };
239
+ },
240
+ };
241
+ const engine = new Engine({ provider, tools: freshTools() });
242
+ const res = await engine.runAgentic([{ role: 'user', content: 'issue HCK' }]);
243
+ expect(histories).toEqual([1, 3]);
244
+ expect(res.text).not.toContain('tool_call');
245
+ expect(res.text).toMatch(/couldn't put together a valid request/i);
246
+ });
247
+
248
+ it('does not count the retry against maxTurns and explains a cut-off call', async () => {
249
+ const seen: string[] = [];
250
+ let n = 0;
251
+ const provider: LLMProvider = {
252
+ name: 'cutoff',
253
+ async runTurn(input) {
254
+ n += 1;
255
+ seen.push(input.messages[input.messages.length - 1]!.content);
256
+ if (n === 1) return { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] };
257
+ if (n === 2) {
258
+ return {
259
+ text: '', rawContent: '<tool_call>{"name":"get_balance"', toolCalls: [],
260
+ toolErrors: [{ code: 'PARSE_ERROR', message: 'eof' }],
261
+ inference: { durationMs: 1, status: 'truncated' },
262
+ };
263
+ }
264
+ return { text: 'You have 50,000 sats.', rawContent: '', toolCalls: [] };
265
+ },
266
+ };
267
+ const engine = new Engine({ provider, tools: freshTools() });
268
+ const res = await engine.runAgentic([{ role: 'user', content: 'balance?' }], { maxTurns: 2 });
269
+ expect(res.text).toBe('You have 50,000 sats.');
270
+ expect(seen[2]).toMatch(/cut off/);
271
+ });
272
+
273
+ it('never returns an empty answer', async () => {
274
+ const engine = new Engine({ provider: scriptedProvider([{ text: '' }]), tools: freshTools() });
275
+ const res = await engine.runAgentic([{ role: 'user', content: 'hi' }]);
276
+ expect(res.text).toMatch(/had to stop/);
277
+ });
278
+
279
+ it('recovers when the retried call parses', async () => {
280
+ const engine = new Engine({
281
+ provider: (() => {
282
+ let n = 0;
283
+ return {
284
+ name: 'retry',
285
+ async runTurn(): Promise<TurnOutput> {
286
+ n += 1;
287
+ if (n === 1) return { text: 'x', rawContent: 'x', toolCalls: [], toolErrors: [{ code: 'PARSE_ERROR', message: 'bad' }] };
288
+ if (n === 2) return { text: '', rawContent: '', toolCalls: [{ name: 'get_balance', arguments: {} }] };
289
+ return { text: 'You have 50,000 sats.', rawContent: '', toolCalls: [] };
290
+ },
291
+ };
292
+ })(),
293
+ tools: freshTools(),
294
+ });
295
+ const res = await engine.runAgentic([{ role: 'user', content: 'balance?' }]);
296
+ expect(balanceTool.handler).toHaveBeenCalledTimes(1);
297
+ expect(res.text).toBe('You have 50,000 sats.');
298
+ });
208
299
  });
209
300
 
210
301
  describe('ToolRegistry', () => {