@kaleidorg/mind 0.5.0 → 0.6.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 (109) 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/funnel.d.ts.map +1 -1
  30. package/dist/funnel.js +12 -0
  31. package/dist/funnel.js.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/knowledge/bitcoin-copilot.js +2 -2
  37. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  38. package/dist/qvac/index.d.ts +1 -1
  39. package/dist/qvac/index.d.ts.map +1 -1
  40. package/dist/qvac/index.js.map +1 -1
  41. package/dist/qvac/parse.d.ts +33 -0
  42. package/dist/qvac/parse.d.ts.map +1 -1
  43. package/dist/qvac/parse.js +69 -5
  44. package/dist/qvac/parse.js.map +1 -1
  45. package/dist/qvac/provider.d.ts +16 -0
  46. package/dist/qvac/provider.d.ts.map +1 -1
  47. package/dist/qvac/provider.js +17 -1
  48. package/dist/qvac/provider.js.map +1 -1
  49. package/dist/qvac/stream.d.ts +16 -0
  50. package/dist/qvac/stream.d.ts.map +1 -1
  51. package/dist/qvac/stream.js +21 -1
  52. package/dist/qvac/stream.js.map +1 -1
  53. package/dist/qvac/text.d.ts.map +1 -1
  54. package/dist/qvac/text.js +4 -0
  55. package/dist/qvac/text.js.map +1 -1
  56. package/dist/recipe/buy-asset-channel.d.ts +1 -1
  57. package/dist/recipe/buy-asset-channel.d.ts.map +1 -1
  58. package/dist/recipe/buy-asset-channel.js +4 -3
  59. package/dist/recipe/buy-asset-channel.js.map +1 -1
  60. package/dist/recipe/kaleidoswap-atomic.d.ts +1 -1
  61. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  62. package/dist/recipe/kaleidoswap-atomic.js +5 -4
  63. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  64. package/dist/recipe/runner.d.ts.map +1 -1
  65. package/dist/recipe/runner.js +38 -0
  66. package/dist/recipe/runner.js.map +1 -1
  67. package/dist/tools/mcp.d.ts +19 -0
  68. package/dist/tools/mcp.d.ts.map +1 -1
  69. package/dist/tools/mcp.js +51 -9
  70. package/dist/tools/mcp.js.map +1 -1
  71. package/package.json +2 -1
  72. package/skills/channel-manager/SKILL.md +59 -0
  73. package/skills/dca/SKILL.md +48 -0
  74. package/skills/kaleido-lsps/SKILL.md +12 -12
  75. package/skills/kaleido-trading/SKILL.md +1 -1
  76. package/skills/liquidity-optimizer/SKILL.md +91 -0
  77. package/skills/merchant-finder/SKILL.md +1 -1
  78. package/skills/portfolio-manager/SKILL.md +67 -0
  79. package/skills/rgb-lightning-node/SKILL.md +3 -3
  80. package/skills/wallet-assistant/SKILL.md +1 -1
  81. package/src/autonomy/autonomy.test.ts +348 -0
  82. package/src/autonomy/index.ts +50 -0
  83. package/src/autonomy/prompt.ts +48 -0
  84. package/src/autonomy/risk.ts +139 -0
  85. package/src/autonomy/run-state.ts +144 -0
  86. package/src/autonomy/scheduler.ts +120 -0
  87. package/src/autonomy/task-store.ts +167 -0
  88. package/src/autonomy/types.ts +186 -0
  89. package/src/funnel.mind.test.ts +390 -0
  90. package/src/funnel.ts +14 -0
  91. package/src/index.ts +41 -0
  92. package/src/knowledge/bitcoin-copilot.ts +2 -2
  93. package/src/qvac/index.ts +1 -0
  94. package/src/qvac/parse.test.ts +70 -1
  95. package/src/qvac/parse.ts +91 -5
  96. package/src/qvac/provider.test.ts +17 -0
  97. package/src/qvac/provider.ts +37 -1
  98. package/src/qvac/stream.test.ts +25 -0
  99. package/src/qvac/stream.ts +38 -1
  100. package/src/qvac/text.ts +4 -0
  101. package/src/recipe/buy-asset-channel.test.ts +5 -0
  102. package/src/recipe/buy-asset-channel.ts +6 -3
  103. package/src/recipe/kaleidoswap-atomic.test.ts +3 -3
  104. package/src/recipe/kaleidoswap-atomic.ts +5 -4
  105. package/src/recipe/recipe.test.ts +16 -0
  106. package/src/recipe/runner.ts +41 -0
  107. package/src/tools/mcp.live.test.ts +116 -0
  108. package/src/tools/mcp.parse.test.ts +37 -0
  109. package/src/tools/mcp.ts +55 -9
@@ -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
+ });
package/src/funnel.ts CHANGED
@@ -302,6 +302,20 @@ export class Funnel {
302
302
  let scoped: string[] | undefined;
303
303
  if (allowedTools) {
304
304
  scoped = [...new Set([...allowedTools, ...ambient])];
305
+ // Resilience against host tool-name drift: a skill's allowlist may name
306
+ // tools that don't exist on this host (e.g. the skill says `get_balances`
307
+ // but the desktop MCP exposes `rln_get_balances`). engine.runAgentic
308
+ // filters the model's tools to this list, so a fully-mismatched skill
309
+ // leaves the model TOOL-LESS — it then narrates "the tool isn't available"
310
+ // instead of acting. If NONE of the scoped tools resolve against the live
311
+ // registry, widen to the full surface so the agent can still work.
312
+ const present = new Set((await this.registry.listTools()).map((t) => t.name));
313
+ if (!scoped.some((n) => present.has(n))) {
314
+ this.log(
315
+ `tier=agentic: skill '${skill?.name ?? '?'}' tools resolved to 0 live tools — using full tool surface`,
316
+ );
317
+ scoped = undefined;
318
+ }
305
319
  } else if (disabledAmbient.length) {
306
320
  // No skill matched but a toggle is off: expose everything except the
307
321
  // disabled ambient tools (the sources stay mounted — no rebuild).
package/src/index.ts CHANGED
@@ -186,3 +186,44 @@ export type { Skill, SkillReference, SkillSelector } from './skills/types.js';
186
186
 
187
187
  export { TurnLogger, defaultMask } from './logger.js';
188
188
  export type { TurnLog, Device, LoggerIO, LoggerOptions } from './logger.js';
189
+
190
+ // ── Autonomy (the task brain: scheduled tasks + run history + spend guardrails)
191
+ // The operational half of the agent's memory — the state nanobot kept in
192
+ // tasks.json + cron + run history, lifted into core (storage/timers injected).
193
+ export {
194
+ InMemoryTaskStore,
195
+ defaultTaskSeeds,
196
+ TaskRunLog,
197
+ createTaskScheduler,
198
+ evaluateSpend,
199
+ DEFAULT_RISK_LIMITS,
200
+ buildTaskPrompt,
201
+ ZERO_ALLOCATION,
202
+ } from './autonomy/index.js';
203
+ export type {
204
+ TaskAllocation,
205
+ AgentTask,
206
+ NewTask,
207
+ TaskSeed,
208
+ TaskStore,
209
+ TaskStoreIO,
210
+ TaskStoreOptions,
211
+ TaskRunCost,
212
+ TaskStats,
213
+ TaskRunRecord,
214
+ RunLogSnapshot,
215
+ RunLogIO,
216
+ RunLogOptions,
217
+ TaskRunOutcome,
218
+ RunTask,
219
+ TimerHandle,
220
+ SchedulerOptions,
221
+ TaskScheduler,
222
+ SpendKind,
223
+ RiskLimits,
224
+ SpendAction,
225
+ RiskContext,
226
+ RiskOutcome,
227
+ RiskVerdict,
228
+ TaskPromptOptions,
229
+ } from './autonomy/index.js';
@@ -218,8 +218,8 @@ export const BITCOIN_COPILOT_DOCS: RagDocument[] = [
218
218
  'channel size you can buy, fees, accepted payment options). It is NOT ' +
219
219
  'your current inbound capacity — it describes what the LSP is willing ' +
220
220
  'to sell you. To learn your CURRENT receive capacity, sum the remote ' +
221
- 'balance of your existing channels; to BUY MORE, use lsp_get_info and ' +
222
- 'lsp_create_order.',
221
+ 'balance of your existing channels; to BUY MORE, use kaleidoswap_lsp_get_info and ' +
222
+ 'kaleidoswap_lsp_create_order.',
223
223
  metadata: { topic: 'channels' },
224
224
  },
225
225
  {
package/src/qvac/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  finalToTurn,
28
28
  type QvacFinalLike,
29
29
  type ParsedTurn,
30
+ type QvacTurnStats,
30
31
  } from './parse.js';
31
32
 
32
33
  export {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { finalToTurn } from './parse.js';
2
+ import { finalToTurn, extractTextToolCalls } from './parse.js';
3
3
 
4
4
  describe('finalToTurn', () => {
5
5
  it('uses contentText for visible text and strips reasoning', () => {
@@ -49,4 +49,73 @@ describe('finalToTurn', () => {
49
49
  const out = finalToTurn({});
50
50
  expect(out).toEqual({ text: '', rawContent: '', toolCalls: [], truncated: false, stopReason: undefined });
51
51
  });
52
+
53
+ // The QVAC SDK / small models sometimes emit tool calls as plain text instead
54
+ // of structured frames; finalToTurn must recover them so they still execute.
55
+ describe('inline tool-call recovery (SDK gave no structured toolCalls)', () => {
56
+ it('recovers a <tool_call> block and hides the tags from the answer', () => {
57
+ const out = finalToTurn({
58
+ contentText:
59
+ '<tool_call> {"name": "rln_create_rgb_invoice", "arguments": {}} </tool_call>',
60
+ });
61
+ expect(out.toolCalls).toEqual([{ name: 'rln_create_rgb_invoice', arguments: {} }]);
62
+ expect(out.text).toBe('');
63
+ });
64
+
65
+ it('keeps the trailing sentence after the tag out of the answer but runs the call', () => {
66
+ const out = finalToTurn({
67
+ contentText:
68
+ '<tool_call> {"name": "rln_create_rgb_invoice", "arguments": {}} </tool_call> Please specify the asset ID.',
69
+ });
70
+ expect(out.toolCalls).toEqual([{ name: 'rln_create_rgb_invoice', arguments: {} }]);
71
+ expect(out.text).toBe('Please specify the asset ID.');
72
+ });
73
+
74
+ it('recovers nested arguments', () => {
75
+ const out = finalToTurn({
76
+ contentText:
77
+ '<tool_call> {"name": "lsp_get_order", "arguments": {"order_id": "latest", "access_token": "latest"}} </tool_call>',
78
+ });
79
+ expect(out.toolCalls).toEqual([
80
+ { name: 'lsp_get_order', arguments: { order_id: 'latest', access_token: 'latest' } },
81
+ ]);
82
+ });
83
+
84
+ it('recovers a bare leading tool-call object', () => {
85
+ const out = finalToTurn({ contentText: '{"name": "get_balances", "arguments": {}}' });
86
+ expect(out.toolCalls).toEqual([{ name: 'get_balances', arguments: {} }]);
87
+ });
88
+
89
+ it('does NOT recover when the SDK already returned structured calls', () => {
90
+ const out = finalToTurn({
91
+ contentText: '<tool_call> {"name": "ghost", "arguments": {}} </tool_call>',
92
+ toolCalls: [{ name: 'real_tool', arguments: { a: 1 } }],
93
+ });
94
+ expect(out.toolCalls).toEqual([{ id: undefined, name: 'real_tool', arguments: { a: 1 } }]);
95
+ });
96
+
97
+ it('ignores JSON the model is merely talking about (not a call)', () => {
98
+ const out = finalToTurn({
99
+ contentText: 'A tool call looks like {"name": "x", "arguments": {}} in JSON.',
100
+ });
101
+ expect(out.toolCalls).toEqual([]);
102
+ expect(out.text).toContain('A tool call looks like');
103
+ });
104
+ });
105
+
106
+ describe('extractTextToolCalls', () => {
107
+ it('extracts multiple tagged calls', () => {
108
+ const calls = extractTextToolCalls(
109
+ '<tool_call>{"name":"a","arguments":{}}</tool_call> and <tool_call>{"name":"b","arguments":{"x":1}}</tool_call>',
110
+ );
111
+ expect(calls).toEqual([
112
+ { name: 'a', arguments: {} },
113
+ { name: 'b', arguments: { x: 1 } },
114
+ ]);
115
+ });
116
+
117
+ it('returns [] for plain prose', () => {
118
+ expect(extractTextToolCalls('just a normal answer')).toEqual([]);
119
+ });
120
+ });
52
121
  });