@kaleidorg/mind 0.5.1 → 0.6.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 (174) hide show
  1. package/dist/autonomy/index.d.ts +21 -0
  2. package/dist/autonomy/index.d.ts.map +1 -0
  3. package/dist/autonomy/index.js +16 -0
  4. package/dist/autonomy/index.js.map +1 -0
  5. package/dist/autonomy/prompt.d.ts +21 -0
  6. package/dist/autonomy/prompt.d.ts.map +1 -0
  7. package/dist/autonomy/prompt.js +37 -0
  8. package/dist/autonomy/prompt.js.map +1 -0
  9. package/dist/autonomy/risk.d.ts +53 -0
  10. package/dist/autonomy/risk.d.ts.map +1 -0
  11. package/dist/autonomy/risk.js +74 -0
  12. package/dist/autonomy/risk.js.map +1 -0
  13. package/dist/autonomy/run-state.d.ts +39 -0
  14. package/dist/autonomy/run-state.d.ts.map +1 -0
  15. package/dist/autonomy/run-state.js +118 -0
  16. package/dist/autonomy/run-state.js.map +1 -0
  17. package/dist/autonomy/scheduler.d.ts +18 -0
  18. package/dist/autonomy/scheduler.d.ts.map +1 -0
  19. package/dist/autonomy/scheduler.js +113 -0
  20. package/dist/autonomy/scheduler.js.map +1 -0
  21. package/dist/autonomy/task-store.d.ts +44 -0
  22. package/dist/autonomy/task-store.d.ts.map +1 -0
  23. package/dist/autonomy/task-store.js +139 -0
  24. package/dist/autonomy/task-store.js.map +1 -0
  25. package/dist/autonomy/types.d.ts +164 -0
  26. package/dist/autonomy/types.d.ts.map +1 -0
  27. package/dist/autonomy/types.js +20 -0
  28. package/dist/autonomy/types.js.map +1 -0
  29. package/dist/bitrefill/contract.d.ts +60 -0
  30. package/dist/bitrefill/contract.d.ts.map +1 -0
  31. package/dist/bitrefill/contract.js +119 -0
  32. package/dist/bitrefill/contract.js.map +1 -0
  33. package/dist/context/compress.d.ts +65 -0
  34. package/dist/context/compress.d.ts.map +1 -0
  35. package/dist/context/compress.js +181 -0
  36. package/dist/context/compress.js.map +1 -0
  37. package/dist/engine.d.ts +20 -0
  38. package/dist/engine.d.ts.map +1 -1
  39. package/dist/engine.js +23 -4
  40. package/dist/engine.js.map +1 -1
  41. package/dist/evidence.d.ts +62 -0
  42. package/dist/evidence.d.ts.map +1 -0
  43. package/dist/evidence.js +47 -0
  44. package/dist/evidence.js.map +1 -0
  45. package/dist/flashnet/contract.d.ts +56 -0
  46. package/dist/flashnet/contract.d.ts.map +1 -0
  47. package/dist/flashnet/contract.js +100 -0
  48. package/dist/flashnet/contract.js.map +1 -0
  49. package/dist/funnel.d.ts +11 -0
  50. package/dist/funnel.d.ts.map +1 -1
  51. package/dist/funnel.js +62 -7
  52. package/dist/funnel.js.map +1 -1
  53. package/dist/index.d.ts +12 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +11 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/kaleidoswap/contract.js +1 -1
  58. package/dist/kaleidoswap/contract.js.map +1 -1
  59. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  60. package/dist/knowledge/bitcoin-copilot.js +85 -2
  61. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  62. package/dist/providers/types.d.ts +17 -0
  63. package/dist/providers/types.d.ts.map +1 -1
  64. package/dist/qvac/index.d.ts +1 -1
  65. package/dist/qvac/index.d.ts.map +1 -1
  66. package/dist/qvac/index.js.map +1 -1
  67. package/dist/qvac/parse.d.ts +18 -0
  68. package/dist/qvac/parse.d.ts.map +1 -1
  69. package/dist/qvac/parse.js +1 -0
  70. package/dist/qvac/parse.js.map +1 -1
  71. package/dist/qvac/provider.d.ts +16 -0
  72. package/dist/qvac/provider.d.ts.map +1 -1
  73. package/dist/qvac/provider.js +40 -1
  74. package/dist/qvac/provider.js.map +1 -1
  75. package/dist/qvac/stream.d.ts +22 -0
  76. package/dist/qvac/stream.d.ts.map +1 -1
  77. package/dist/qvac/stream.js +33 -1
  78. package/dist/qvac/stream.js.map +1 -1
  79. package/dist/recipe/buy-asset-channel.d.ts +1 -1
  80. package/dist/recipe/buy-asset-channel.d.ts.map +1 -1
  81. package/dist/recipe/buy-asset-channel.js +4 -3
  82. package/dist/recipe/buy-asset-channel.js.map +1 -1
  83. package/dist/recipe/flashnet-swap.d.ts +35 -0
  84. package/dist/recipe/flashnet-swap.d.ts.map +1 -0
  85. package/dist/recipe/flashnet-swap.js +239 -0
  86. package/dist/recipe/flashnet-swap.js.map +1 -0
  87. package/dist/recipe/kaleidoswap-atomic.d.ts +1 -1
  88. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  89. package/dist/recipe/kaleidoswap-atomic.js +42 -20
  90. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  91. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -1
  92. package/dist/recipe/kaleidoswap-channel-order.js +31 -10
  93. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -1
  94. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -1
  95. package/dist/recipe/kaleidoswap-price.js +7 -1
  96. package/dist/recipe/kaleidoswap-price.js.map +1 -1
  97. package/dist/recipe/runner.d.ts.map +1 -1
  98. package/dist/recipe/runner.js +43 -3
  99. package/dist/recipe/runner.js.map +1 -1
  100. package/dist/recipe/swap.d.ts.map +1 -1
  101. package/dist/recipe/swap.js +14 -1
  102. package/dist/recipe/swap.js.map +1 -1
  103. package/dist/tools/mcp.d.ts +19 -0
  104. package/dist/tools/mcp.d.ts.map +1 -1
  105. package/dist/tools/mcp.js +51 -9
  106. package/dist/tools/mcp.js.map +1 -1
  107. package/dist/wallet/confirm.d.ts.map +1 -1
  108. package/dist/wallet/confirm.js +1 -0
  109. package/dist/wallet/confirm.js.map +1 -1
  110. package/dist/wallet/contract.d.ts.map +1 -1
  111. package/dist/wallet/contract.js +20 -4
  112. package/dist/wallet/contract.js.map +1 -1
  113. package/package.json +5 -4
  114. package/skills/bitrefill/SKILL.md +152 -52
  115. package/skills/channel-manager/SKILL.md +59 -0
  116. package/skills/dca/SKILL.md +48 -0
  117. package/skills/flashnet-swaps/SKILL.md +158 -0
  118. package/skills/kaleido-lsps/SKILL.md +34 -17
  119. package/skills/kaleido-trading/SKILL.md +37 -13
  120. package/skills/liquidity-optimizer/SKILL.md +91 -0
  121. package/skills/merchant-finder/SKILL.md +2 -2
  122. package/skills/portfolio-manager/SKILL.md +67 -0
  123. package/skills/rgb-lightning-node/SKILL.md +38 -11
  124. package/skills/spark-wallet/SKILL.md +235 -0
  125. package/skills/wallet-assistant/SKILL.md +2 -2
  126. package/src/autonomy/autonomy.test.ts +348 -0
  127. package/src/autonomy/index.ts +50 -0
  128. package/src/autonomy/prompt.ts +48 -0
  129. package/src/autonomy/risk.ts +139 -0
  130. package/src/autonomy/run-state.ts +144 -0
  131. package/src/autonomy/scheduler.ts +120 -0
  132. package/src/autonomy/task-store.ts +167 -0
  133. package/src/autonomy/types.ts +186 -0
  134. package/src/bitrefill/contract.test.ts +89 -0
  135. package/src/bitrefill/contract.ts +190 -0
  136. package/src/context/compress.test.ts +120 -0
  137. package/src/context/compress.ts +230 -0
  138. package/src/engine.test.ts +34 -0
  139. package/src/engine.ts +35 -4
  140. package/src/evidence.test.ts +80 -0
  141. package/src/evidence.ts +114 -0
  142. package/src/flashnet/contract.test.ts +101 -0
  143. package/src/flashnet/contract.ts +164 -0
  144. package/src/funnel.mind.test.ts +390 -0
  145. package/src/funnel.ts +73 -8
  146. package/src/index.ts +92 -1
  147. package/src/kaleidoswap/contract.ts +1 -1
  148. package/src/knowledge/bitcoin-copilot.ts +96 -2
  149. package/src/providers/types.ts +18 -0
  150. package/src/qvac/index.ts +1 -0
  151. package/src/qvac/parse.ts +20 -0
  152. package/src/qvac/provider.test.ts +17 -0
  153. package/src/qvac/provider.ts +62 -2
  154. package/src/qvac/stream.test.ts +36 -0
  155. package/src/qvac/stream.ts +54 -1
  156. package/src/recipe/buy-asset-channel.test.ts +5 -0
  157. package/src/recipe/buy-asset-channel.ts +6 -3
  158. package/src/recipe/flashnet-swap.test.ts +114 -0
  159. package/src/recipe/flashnet-swap.ts +266 -0
  160. package/src/recipe/kaleidoswap-atomic.test.ts +24 -3
  161. package/src/recipe/kaleidoswap-atomic.ts +39 -20
  162. package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
  163. package/src/recipe/kaleidoswap-channel-order.ts +27 -9
  164. package/src/recipe/kaleidoswap-price.ts +7 -1
  165. package/src/recipe/recipe.test.ts +21 -0
  166. package/src/recipe/runner.ts +46 -3
  167. package/src/recipe/swap.ts +16 -1
  168. package/src/tools/mcp.live.test.ts +116 -0
  169. package/src/tools/mcp.parse.test.ts +37 -0
  170. package/src/tools/mcp.ts +55 -9
  171. package/src/wallet/confirm.test.ts +8 -0
  172. package/src/wallet/confirm.ts +1 -0
  173. package/src/wallet/contract.test.ts +10 -0
  174. package/src/wallet/contract.ts +26 -4
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Canonical Flashnet tool contract — AMM swaps on Spark.
3
+ *
4
+ * Flashnet is a Spark-native AMM (constant-product + V3 concentrated-liquidity
5
+ * pools). The agent surface is small and intent-aligned:
6
+ *
7
+ * flashnet_list_pools — discover pools by asset pair (read)
8
+ * flashnet_get_pool — pool details + reserves (read)
9
+ * flashnet_simulate_swap — quote a swap, no funds move (read)
10
+ * flashnet_execute_swap — SPEND, confirmation-gated (the swap itself)
11
+ * flashnet_get_balance — Spark wallet balance (BTC + tokens) as the
12
+ * AMM client sees it
13
+ *
14
+ * The model picks a pool, simulates to see the rate/output, optionally shows
15
+ * the user the quote, and then executes. The host's `FlashnetClient` (built
16
+ * over a `SparkWallet`) does the actual signing.
17
+ *
18
+ * Asset addresses on Flashnet:
19
+ * - BTC is a constant pubkey: `BTC_ASSET_PUBKEY` per network.
20
+ * - Tokens are Spark Bech32m token identifiers (or hex; the client coerces).
21
+ *
22
+ * Pure data — no deps, RN-safe.
23
+ */
24
+
25
+ import type { ToolDef } from '../types.js';
26
+ import { InProcessToolSource } from '../tools/in-process.js';
27
+ import type { InProcessTool } from '../tools/in-process.js';
28
+
29
+ export interface FlashnetToolDef extends ToolDef {
30
+ /** Moves funds → confirmation-gated. */
31
+ spend?: boolean;
32
+ }
33
+
34
+ type Props = Record<
35
+ string,
36
+ { type: string; description?: string; enum?: string[]; items?: unknown }
37
+ >;
38
+
39
+ function t(
40
+ name: string,
41
+ description: string,
42
+ properties: Props = {},
43
+ required: string[] = [],
44
+ spend = false,
45
+ ): FlashnetToolDef {
46
+ return {
47
+ name,
48
+ description,
49
+ spend,
50
+ requiresConfirmation: spend,
51
+ parameters: { type: 'object', properties, required },
52
+ };
53
+ }
54
+
55
+ /** Canonical Flashnet tools. */
56
+ export const FLASHNET_TOOLS: FlashnetToolDef[] = [
57
+ t(
58
+ 'flashnet_list_pools',
59
+ "List Flashnet AMM pools. Filter by asset pair to find a venue for a swap (e.g. asset_a=BTC, asset_b=<USDB address> → pools that swap between them). Returns pools sorted by TVL by default. Use this BEFORE simulate_swap to pick a `pool_id`.",
60
+ {
61
+ asset_a: { type: 'string', description: 'OPTIONAL — first asset address (BTC pubkey or Spark token id). Filters pools.' },
62
+ asset_b: { type: 'string', description: 'OPTIONAL — second asset address. Filters pools containing both assets.' },
63
+ sort: { type: 'string', description: 'OPTIONAL — sort order. Default TVL_DESC.', enum: ['TVL_DESC', 'TVL_ASC', 'VOLUME24H_DESC', 'VOLUME24H_ASC', 'CREATED_AT_DESC', 'CREATED_AT_ASC'] },
64
+ limit: { type: 'number', description: 'OPTIONAL — max pools (1–50, default 10).' },
65
+ },
66
+ ),
67
+
68
+ t(
69
+ 'flashnet_get_pool',
70
+ "Get a single pool's details — reserves, fees, current price, TVL. Use after flashnet_list_pools when the user wants to inspect a pool before swapping.",
71
+ {
72
+ pool_id: { type: 'string', description: 'Pool id (LP pubkey) from flashnet_list_pools.' },
73
+ },
74
+ ['pool_id'],
75
+ ),
76
+
77
+ t(
78
+ 'flashnet_simulate_swap',
79
+ "Simulate a swap WITHOUT executing — returns `amount_out`, `execution_price`, `price_impact_pct`, `fee_paid`. Read-only. Use this to quote the user before they confirm. The `amount_in` is in smallest units of `asset_in` (sats for BTC, smallest unit of the token).",
80
+ {
81
+ pool_id: { type: 'string', description: 'Pool id from flashnet_list_pools.' },
82
+ asset_in_address: { type: 'string', description: 'Address of the asset the user is selling (BTC pubkey or Spark token id).' },
83
+ asset_out_address: { type: 'string', description: 'Address of the asset the user is buying.' },
84
+ amount_in: { type: 'string', description: 'Amount to swap, in smallest units of asset_in. Strings (BigInt-safe). e.g. "100000" for 100k sats.' },
85
+ },
86
+ ['pool_id', 'asset_in_address', 'asset_out_address', 'amount_in'],
87
+ ),
88
+
89
+ t(
90
+ 'flashnet_execute_swap',
91
+ "SPEND: confirmation-gated. Execute a swap quoted by flashnet_simulate_swap. `min_amount_out` and `max_slippage_bps` cap the worst-case fill (basis points: 100 = 1%, 50 = 0.5%). Returns the swap request id and the amount actually received.",
92
+ {
93
+ pool_id: { type: 'string', description: 'Pool id from flashnet_list_pools.' },
94
+ asset_in_address: { type: 'string' },
95
+ asset_out_address: { type: 'string' },
96
+ amount_in: { type: 'string', description: 'Amount to swap, smallest units.' },
97
+ min_amount_out: { type: 'string', description: 'Minimum acceptable output, smallest units. Calculate from simulate_swap.amount_out × (1 − max_slippage_bps/10000) and pass that — never trust the simulated value as-is.' },
98
+ max_slippage_bps: { type: 'number', description: 'Maximum slippage in basis points (default 50 = 0.5%). 100 = 1%, 500 = 5%.' },
99
+ },
100
+ ['pool_id', 'asset_in_address', 'asset_out_address', 'amount_in', 'min_amount_out'],
101
+ /* spend */ true,
102
+ ),
103
+
104
+ t(
105
+ 'flashnet_get_balance',
106
+ "Get the Spark wallet's BTC + token balances as the Flashnet client sees them. Useful to verify the user has enough of asset_in before quoting or executing a swap. Returns `{ btc_sats, tokens: [{ address, balance, symbol?, decimals? }] }`.",
107
+ ),
108
+ ];
109
+
110
+ /** All Flashnet tool names that move funds (confirmation-gated). */
111
+ export const FLASHNET_SPEND_TOOLS: Set<string> = new Set(
112
+ FLASHNET_TOOLS.filter((t) => t.spend).map((t) => t.name),
113
+ );
114
+
115
+ export function isFlashnetSpendTool(name: string): boolean {
116
+ return FLASHNET_SPEND_TOOLS.has(name);
117
+ }
118
+
119
+ export function getFlashnetTool(name: string): FlashnetToolDef | undefined {
120
+ return FLASHNET_TOOLS.find((t) => t.name === name);
121
+ }
122
+
123
+ /** A handler bound to one Flashnet tool. */
124
+ export type FlashnetHandler = (args: Record<string, unknown>) => Promise<unknown>;
125
+
126
+ export interface BindFlashnetOptions {
127
+ /** Skip tools without a handler instead of throwing (default false). */
128
+ allowMissing?: boolean;
129
+ /** ToolSource id for the registry (default 'flashnet'). */
130
+ id?: string;
131
+ }
132
+
133
+ /**
134
+ * Bind Flashnet contract tools to in-process handlers → an InProcessToolSource.
135
+ *
136
+ * const source = bindFlashnetTools({
137
+ * flashnet_list_pools: async (a) => client.listPools(a),
138
+ * flashnet_get_pool: async ({ pool_id }) => client.getPoolDetails(pool_id),
139
+ * flashnet_simulate_swap: async (a) => client.simulateSwap(a),
140
+ * flashnet_execute_swap: async (a) => client.executeSwap(a),
141
+ * flashnet_get_balance: async () => client.getBalance(),
142
+ * });
143
+ */
144
+ export function bindFlashnetTools(
145
+ handlers: Record<string, FlashnetHandler>,
146
+ opts: BindFlashnetOptions = {},
147
+ ): InProcessToolSource {
148
+ const bound: InProcessTool[] = [];
149
+ for (const def of FLASHNET_TOOLS) {
150
+ const handler = handlers[def.name];
151
+ if (!handler) {
152
+ if (opts.allowMissing) continue;
153
+ throw new Error(`bindFlashnetTools: no handler for "${def.name}"`);
154
+ }
155
+ bound.push({
156
+ name: def.name,
157
+ description: def.description,
158
+ parameters: def.parameters,
159
+ requiresConfirmation: def.requiresConfirmation,
160
+ handler,
161
+ });
162
+ }
163
+ return new InProcessToolSource(opts.id ?? 'flashnet', bound);
164
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Desktop "mind" smoke tests — drive the SAME Funnel the desktop sidecar builds
3
+ * (apps/provider/src/index.ts: recipes [buyAssetChannel, kaleidoswapAtomic,
4
+ * assetSend, payments, receive] over the MCP tool surface) through each
5
+ * user-facing intent, end to end, with a SCRIPTED provider standing in for the
6
+ * on-device QVAC model.
7
+ *
8
+ * Why mind-level (not just MCP-level, which mcp.live.test.ts covers): the
9
+ * desktop "tool-less" bugs live in the wiring BETWEEN the brain and the tools —
10
+ * tier routing (fast/recipe/agentic), recipe orchestration, and agentic tool
11
+ * selection. These assert that, given a real tool surface, the mind:
12
+ * - balance → agentic → calls rln_get_balances, surfaces the balance
13
+ * - list channels → agentic → calls rln_list_channels
14
+ * - buy via swap → recipe → quote → init → node → whitelist → execute (1 confirm)
15
+ * - merchant near city → agentic → search_knowledge over the merchant corpus
16
+ *
17
+ * Fully deterministic (no node/model/maker), so it runs in CI. Live tool
18
+ * execution against a real node is the separate mcp.live.test.ts.
19
+ */
20
+ import { describe, expect, it } from 'vitest';
21
+ import { Funnel } from './funnel.js';
22
+ import { ToolRegistry } from './tools/registry.js';
23
+ import { InProcessToolSource } from './tools/in-process.js';
24
+ import { merchantsToDocuments } from './knowledge/merchants.js';
25
+ import { buyAssetChannelRecipe } from './recipe/buy-asset-channel.js';
26
+ import { kaleidoswapAtomicRecipe } from './recipe/kaleidoswap-atomic.js';
27
+ import { assetSendRecipe } from './recipe/asset-send.js';
28
+ import { paymentsRecipe } from './recipe/payments.js';
29
+ import { receiveRecipe } from './recipe/receive.js';
30
+ import { loadSkillsDir, packagedSkillsDir } from './skills/loader.js';
31
+ import type { Skill } from './skills/types.js';
32
+ import type { LLMProvider, TurnInput, TurnOutput } from './providers/types.js';
33
+ import type { ConfirmDecision, ToolCall } from './types.js';
34
+
35
+ // The exact recipe set the desktop provider registers, in order. Order matters:
36
+ // kaleidoswapAtomicRecipe is FIRST, so a plain "buy 1 USDT" on a funded node
37
+ // routes to the atomic SWAP (BTC→USDT over existing liquidity). The
38
+ // channel-onboarding recipe wins only for explicit channel/inbound/liquidity
39
+ // phrasing, which the atomic matcher excludes. (See the routing tests below.)
40
+ const DESKTOP_RECIPES = [
41
+ kaleidoswapAtomicRecipe,
42
+ buyAssetChannelRecipe,
43
+ assetSendRecipe,
44
+ paymentsRecipe,
45
+ receiveRecipe,
46
+ ];
47
+
48
+ // ── A scripted provider: each script entry is one model turn. Returning tool
49
+ // calls makes the agentic engine execute them and ask for the next turn. ──
50
+ function scripted(script: Array<{ text: string; toolCalls?: ToolCall[] }>): LLMProvider {
51
+ let turn = 0;
52
+ return {
53
+ name: 'scripted',
54
+ async runTurn(input: TurnInput): Promise<TurnOutput> {
55
+ const step = script[Math.min(turn, script.length - 1)];
56
+ turn += 1;
57
+ input.onToken?.(step.text);
58
+ return { text: step.text, rawContent: step.text, toolCalls: step.toolCalls ?? [], requestId: `req-${turn}` };
59
+ },
60
+ };
61
+ }
62
+
63
+ // ── Merchant corpus: real merchantsToDocuments transform over a small fixture,
64
+ // queried by city so "near Rome" surfaces the Rome places (not Milan). ──
65
+ const MERCHANTS = [
66
+ { id: 'm1', name: 'Bitcoin Caffè', category: 'cafe', city: 'Rome', address: 'Via Roma 1', acceptedAssets: ['lightning', 'onchain'] },
67
+ { id: 'm2', name: 'Satoshi Pizzeria', category: 'restaurant', city: 'Milan', acceptedAssets: ['lightning'] },
68
+ { id: 'm3', name: 'Nakamoto Books', category: 'shop', city: 'Rome', address: 'Via Veneto 9', acceptedAssets: ['onchain'] },
69
+ ];
70
+ const MERCHANT_DOCS = merchantsToDocuments(MERCHANTS);
71
+ function searchMerchants(query: string): string {
72
+ const q = query.toLowerCase();
73
+ const hits = MERCHANT_DOCS.filter((d) => {
74
+ const city = String((d.metadata as { city?: string })?.city ?? '').toLowerCase();
75
+ return city.length > 0 && q.includes(city);
76
+ });
77
+ return hits.length ? hits.map((h, i) => `[${i + 1}] ${h.text}`).join('\n\n') : 'No relevant passages found.';
78
+ }
79
+
80
+ /**
81
+ * Build the desktop mind with canned MCP-named tools. Every call is recorded in
82
+ * `calls` (name + args, in execution order) so we can assert routing + sequence.
83
+ */
84
+ function buildMind(
85
+ provider: LLMProvider,
86
+ opts: { skills?: Skill[]; log?: (m: string) => void } = {},
87
+ ): { funnel: Funnel; calls: Array<{ name: string; args: any }> } {
88
+ const calls: Array<{ name: string; args: any }> = [];
89
+ const tool = (name: string, response: any, spend = false) => ({
90
+ name,
91
+ description: '',
92
+ parameters: { type: 'object' as const, properties: {} },
93
+ requiresConfirmation: spend,
94
+ handler: async (a: Record<string, unknown>) => {
95
+ calls.push({ name, args: a });
96
+ return typeof response === 'function' ? response(a) : response;
97
+ },
98
+ });
99
+
100
+ const tools = new ToolRegistry([
101
+ new InProcessToolSource('wallet', [
102
+ // reads
103
+ tool('rln_get_balances', { lightning_balance_sat: 1_949_753, btc_onchain: { vanilla_spendable_sats: 100_000 } }),
104
+ tool('rln_list_channels', {
105
+ channels: [
106
+ { channel_id: '5d4487c8', capacity_sat: 1_000_000, outbound_balance_msat: 987_240_000, ready: true },
107
+ { channel_id: 'a1b2c3d4', capacity_sat: 1_000_000, outbound_balance_msat: 500_000_000, ready: true },
108
+ ],
109
+ }),
110
+ // atomic-swap chain (quote read; init/whitelist/execute are spends)
111
+ tool('kaleidoswap_get_quote', {
112
+ rfq_id: 'rfq-1',
113
+ from_asset: { asset_id: 'BTC', ticker: 'BTC', amount: 100_000 },
114
+ to_asset: { asset_id: 'rgb:USDT', ticker: 'USDT', amount: 1_000_000 },
115
+ from_amount_display: '100,000 sats',
116
+ to_amount_display: '1 USDT',
117
+ fee_display: '154 sats',
118
+ }),
119
+ tool('kaleidoswap_atomic_init', { swapstring: 'SWAP/abc/def', payment_hash: 'ph-1' }, /* spend */ true),
120
+ tool('rln_get_node_info', { pubkey: '030637ec' }),
121
+ tool('rln_atomic_taker', { ok: true }, /* spend */ true),
122
+ tool('kaleidoswap_atomic_execute', { status: 200, message: 'Swap executed successfully.' }, /* spend */ true),
123
+ // LSPS1 asset-channel onboarding (the rail "buy N USDT" routes to)
124
+ tool('kaleidoswap_lsp_quote_asset_channel', {
125
+ total_sat: 29_946,
126
+ btc_amount_sat: 13_807,
127
+ channel_fee_sat: 16_139,
128
+ expires_at: 0,
129
+ }),
130
+ tool('kaleidoswap_lsp_create_asset_channel', { order_id: 'cf2981c4', order_state: 'CREATED' }, /* spend */ true),
131
+ // knowledge (merchant discovery)
132
+ {
133
+ name: 'search_knowledge',
134
+ description: 'Search the knowledge base (merchants, docs) for relevant passages.',
135
+ parameters: { type: 'object' as const, properties: { query: { type: 'string' } }, required: ['query'] },
136
+ handler: async (a: Record<string, unknown>) => {
137
+ calls.push({ name: 'search_knowledge', args: a });
138
+ return searchMerchants(String(a.query ?? ''));
139
+ },
140
+ },
141
+ ]),
142
+ ]);
143
+
144
+ return {
145
+ funnel: new Funnel({ provider, tools, recipes: DESKTOP_RECIPES, maxTurns: 8, skills: opts.skills, log: opts.log }),
146
+ calls,
147
+ };
148
+ }
149
+
150
+ describe('desktop mind — balance', () => {
151
+ it('routes "what\'s my balance?" to the agentic tier and calls rln_get_balances', async () => {
152
+ const { funnel, calls } = buildMind(
153
+ scripted([
154
+ { text: '', toolCalls: [{ name: 'rln_get_balances', arguments: {} }] },
155
+ { text: 'You have 1,949,753 sats in Lightning.' },
156
+ ]),
157
+ );
158
+
159
+ const res = await funnel.runTurn("what's my balance?");
160
+
161
+ expect(res.tier).toBe('agentic');
162
+ expect(calls.map((c) => c.name)).toContain('rln_get_balances');
163
+ const exec = res.toolCalls?.find((c) => c.name === 'rln_get_balances');
164
+ expect((exec?.result as { lightning_balance_sat?: number })?.lightning_balance_sat).toBe(1_949_753);
165
+ expect(res.text).toBeTruthy();
166
+ });
167
+ });
168
+
169
+ describe('desktop mind — list channels', () => {
170
+ it('routes "list my channels" to the agentic tier and calls rln_list_channels', async () => {
171
+ const { funnel, calls } = buildMind(
172
+ scripted([
173
+ { text: '', toolCalls: [{ name: 'rln_list_channels', arguments: {} }] },
174
+ { text: 'You have 2 open channels.' },
175
+ ]),
176
+ );
177
+
178
+ const res = await funnel.runTurn('list my channels');
179
+
180
+ expect(res.tier).toBe('agentic');
181
+ expect(calls.map((c) => c.name)).toContain('rln_list_channels');
182
+ const exec = res.toolCalls?.find((c) => c.name === 'rln_list_channels');
183
+ expect((exec?.result as { channels?: unknown[] })?.channels).toHaveLength(2);
184
+ });
185
+ });
186
+
187
+ describe('desktop mind — buy assets via atomic swap', () => {
188
+ it('routes "swap … for usdt" to the atomic recipe and runs quote→init→node→whitelist→execute with ONE confirm', async () => {
189
+ // The recipe forces a model inference for slot extraction (forceModelExtract):
190
+ // the runner injects a synthetic `extract_request` tool; the model fills slots.
191
+ const provider: LLMProvider = {
192
+ name: 'extract',
193
+ async runTurn(input) {
194
+ if (input.tools?.some((t) => t.name === 'extract_request')) {
195
+ return {
196
+ text: '',
197
+ rawContent: '',
198
+ toolCalls: [
199
+ { id: 'ex1', name: 'extract_request', arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 100_000, amount_side: 'from' } },
200
+ ],
201
+ };
202
+ }
203
+ return { text: '', rawContent: '', toolCalls: [] };
204
+ },
205
+ };
206
+
207
+ const { funnel, calls } = buildMind(provider);
208
+ const confirms: Array<{ name: string; summary?: string }> = [];
209
+
210
+ const res = await funnel.runTurn('swap 100000 sats for usdt', {
211
+ onConfirm: async (call): Promise<ConfirmDecision> => {
212
+ confirms.push({ name: call.name, summary: call.summary });
213
+ return { approved: true };
214
+ },
215
+ });
216
+
217
+ expect(res.tier).toBe('recipe');
218
+ expect(res.route).toBe('kaleidoswap-atomic');
219
+ // The full deterministic chain, in order.
220
+ expect(calls.map((c) => c.name)).toEqual([
221
+ 'kaleidoswap_get_quote',
222
+ 'kaleidoswap_atomic_init',
223
+ 'rln_get_node_info',
224
+ 'rln_atomic_taker',
225
+ 'kaleidoswap_atomic_execute',
226
+ ]);
227
+ // init sources the asset ids + maker-unit amounts straight from the quote.
228
+ const init = calls.find((c) => c.name === 'kaleidoswap_atomic_init')!;
229
+ expect(init.args).toMatchObject({ rfq_id: 'rfq-1', from_asset: 'BTC', to_asset: 'rgb:USDT' });
230
+ // execute carries the node pubkey as taker_pubkey + the maker's payment_hash.
231
+ const exec = calls.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
232
+ expect(exec.args).toMatchObject({ swapstring: 'SWAP/abc/def', taker_pubkey: '030637ec', payment_hash: 'ph-1' });
233
+ // EXACTLY ONE confirmation gate, fired before the first spend, with real numbers.
234
+ expect(confirms).toHaveLength(1);
235
+ expect(confirms[0]!.name).toBe('kaleidoswap_atomic_init');
236
+ expect(confirms[0]!.summary).toMatch(/swap/i);
237
+ expect(res.text).toMatch(/submitted|settling/i);
238
+ });
239
+
240
+ it('routes a plain "buy 1 usdt" to the ATOMIC swap (funded node), not channel onboarding', async () => {
241
+ // On a node with existing BTC liquidity, "buy 1 usdt" = swap BTC→USDT, NOT
242
+ // open a new channel. The model fills the implicit source (BTC) + buy leg.
243
+ const buyExtract: LLMProvider = {
244
+ name: 'extract',
245
+ async runTurn(input) {
246
+ if (input.tools?.some((t) => t.name === 'extract_request')) {
247
+ return {
248
+ text: '',
249
+ rawContent: '',
250
+ toolCalls: [
251
+ { id: 'ex1', name: 'extract_request', arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 1, amount_side: 'to' } },
252
+ ],
253
+ };
254
+ }
255
+ return { text: '', rawContent: '', toolCalls: [] };
256
+ },
257
+ };
258
+
259
+ const { funnel, calls } = buildMind(buyExtract);
260
+ const res = await funnel.runTurn('buy 1 usdt', { onConfirm: async () => ({ approved: true }) });
261
+
262
+ expect(res.tier).toBe('recipe');
263
+ expect(res.route).toBe('kaleidoswap-atomic');
264
+ expect(calls.map((c) => c.name)).toEqual([
265
+ 'kaleidoswap_get_quote',
266
+ 'kaleidoswap_atomic_init',
267
+ 'rln_get_node_info',
268
+ 'rln_atomic_taker',
269
+ 'kaleidoswap_atomic_execute',
270
+ ]);
271
+ });
272
+
273
+ it('routes explicit inbound-liquidity phrasing to channel onboarding', async () => {
274
+ // The channel-onboarding rail still wins for explicit channel/inbound
275
+ // phrasing (the atomic matcher excludes channel/inbound/liquidity).
276
+ const { funnel } = buildMind(scripted([{ text: '' }]));
277
+ const res = await funnel.runTurn('get 100 usdt inbound liquidity', {
278
+ onConfirm: async () => ({ approved: false }),
279
+ });
280
+ expect(res.tier).toBe('recipe');
281
+ expect(res.route).toBe(buyAssetChannelRecipe.name);
282
+ });
283
+ });
284
+
285
+ describe('desktop mind — find a merchant near a city', () => {
286
+ it('routes "where can I spend bitcoin near Rome" to agentic search_knowledge and surfaces the Rome merchants', async () => {
287
+ const { funnel, calls } = buildMind(
288
+ scripted([
289
+ { text: '', toolCalls: [{ name: 'search_knowledge', arguments: { query: 'bitcoin merchants in Rome' } }] },
290
+ { text: 'Near Rome you can spend at Bitcoin Caffè and Nakamoto Books.' },
291
+ ]),
292
+ );
293
+
294
+ const res = await funnel.runTurn('where can I spend bitcoin near Rome?');
295
+
296
+ expect(res.tier).toBe('agentic');
297
+ const sk = calls.find((c) => c.name === 'search_knowledge');
298
+ expect(sk).toBeTruthy();
299
+ expect(String(sk!.args.query)).toMatch(/rome/i);
300
+ // Real retrieval over merchantsToDocuments: Rome places in, Milan out.
301
+ const result = String(res.toolCalls?.find((c) => c.name === 'search_knowledge')?.result ?? '');
302
+ expect(result).toMatch(/Bitcoin Caffè/);
303
+ expect(result).toMatch(/Nakamoto Books/);
304
+ expect(result).not.toMatch(/Satoshi Pizzeria|Milan/);
305
+ });
306
+ });
307
+
308
+ // ─────────────────────────────────────────────────────────────────────
309
+ // Skill scoping — the layer that actually caused the desktop "I cannot check
310
+ // your balance, the tool is not available" bug. The agentic tier filters the
311
+ // model's tools to the SELECTED SKILL's `tools:` allowlist (engine.ts honours
312
+ // allowedTools). If a skill's allowlist names tools that don't exist on the
313
+ // host (e.g. `get_balances` while the desktop MCP exposes `rln_get_balances`),
314
+ // the real tool is filtered out and the model goes tool-less. These load the
315
+ // REAL desktop skills and assert the needed tool survives scoping.
316
+ // (The scenario tests above ran skill-LESS, which is exactly why they missed it.)
317
+ // ─────────────────────────────────────────────────────────────────────
318
+ describe('desktop mind — skill scoping (real skills)', () => {
319
+ const SKILLS = loadSkillsDir(packagedSkillsDir());
320
+
321
+ it('loads the real desktop skills', () => {
322
+ expect(SKILLS.length).toBeGreaterThan(0);
323
+ expect(SKILLS.map((s) => s.name)).toEqual(
324
+ expect.arrayContaining(['wallet-assistant', 'rgb-lightning-node', 'kaleido-trading']),
325
+ );
326
+ });
327
+
328
+ it('wallet-assistant (triggers on "balance") exposes the real rln_*/wdk_* tool names', () => {
329
+ const wallet = SKILLS.find((s) => s.name === 'wallet-assistant')!;
330
+ expect(wallet.tools).toEqual(expect.arrayContaining(['rln_get_balances', 'wdk_get_balances']));
331
+ expect(wallet.tools).toEqual(expect.arrayContaining(['rln_get_address', 'rln_send_btc', 'rln_create_ln_invoice']));
332
+ });
333
+
334
+ it('rgb-lightning-node (triggers on "channels") exposes only canonical rln_* tools', () => {
335
+ const node = SKILLS.find((s) => s.name === 'rgb-lightning-node')!;
336
+ expect(node.tools).toContain('rln_list_channels');
337
+ expect(node.tools?.every((tool) => tool.startsWith('rln_'))).toBe(true);
338
+ });
339
+
340
+ it('kaleido-trading drops the phantom kaleidoswap_get_nodeinfo / get_order_history names', () => {
341
+ const trading = SKILLS.find((s) => s.name === 'kaleido-trading')!;
342
+ expect(trading.tools).not.toContain('kaleidoswap_get_nodeinfo');
343
+ expect(trading.tools).not.toContain('kaleidoswap_get_order_history');
344
+ expect(trading.tools).toEqual(expect.arrayContaining(['kaleidoswap_get_quote', 'kaleidoswap_place_order']));
345
+ expect(trading.tools).not.toEqual(
346
+ expect.arrayContaining([
347
+ 'kaleidoswap_get_spreads',
348
+ 'kaleidoswap_get_open_orders',
349
+ 'kaleidoswap_cancel_order',
350
+ 'kaleidoswap_get_position',
351
+ ]),
352
+ );
353
+ });
354
+
355
+ it('balance through the FULL mind WITH skills loaded still reaches rln_get_balances', async () => {
356
+ const logs: string[] = [];
357
+ const { funnel, calls } = buildMind(
358
+ scripted([
359
+ { text: '', toolCalls: [{ name: 'rln_get_balances', arguments: {} }] },
360
+ { text: 'You have 1,949,753 sats.' },
361
+ ]),
362
+ { skills: SKILLS, log: (m) => logs.push(m) },
363
+ );
364
+
365
+ const res = await funnel.runTurn("what's my balance?");
366
+
367
+ expect(res.tier).toBe('agentic');
368
+ // wallet-assistant is selected AND rln_get_balances survives its scoping…
369
+ const agenticLine = logs.find((l) => l.startsWith('tier=agentic'));
370
+ expect(agenticLine).toMatch(/skill=wallet-assistant/);
371
+ expect(agenticLine).toMatch(/rln_get_balances/);
372
+ // …and the tool actually executes (not narrated).
373
+ expect(calls.map((c) => c.name)).toContain('rln_get_balances');
374
+ });
375
+
376
+ it('list channels through the FULL mind WITH skills loaded reaches rln_list_channels', async () => {
377
+ const { funnel, calls } = buildMind(
378
+ scripted([
379
+ { text: '', toolCalls: [{ name: 'rln_list_channels', arguments: {} }] },
380
+ { text: 'You have 2 channels.' },
381
+ ]),
382
+ { skills: SKILLS },
383
+ );
384
+
385
+ const res = await funnel.runTurn('list my channels');
386
+
387
+ expect(res.tier).toBe('agentic');
388
+ expect(calls.map((c) => c.name)).toContain('rln_list_channels');
389
+ });
390
+ });