@kaleidorg/mind 0.2.0 → 0.3.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 (77) hide show
  1. package/dist/capabilities.d.ts +4 -0
  2. package/dist/capabilities.d.ts.map +1 -1
  3. package/dist/capabilities.js +7 -0
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/engine.d.ts +9 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +1 -0
  8. package/dist/engine.js.map +1 -1
  9. package/dist/funnel.d.ts +6 -0
  10. package/dist/funnel.d.ts.map +1 -1
  11. package/dist/funnel.js +26 -6
  12. package/dist/funnel.js.map +1 -1
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/kaleidoswap/contract.d.ts +72 -0
  18. package/dist/kaleidoswap/contract.d.ts.map +1 -0
  19. package/dist/kaleidoswap/contract.js +125 -0
  20. package/dist/kaleidoswap/contract.js.map +1 -0
  21. package/dist/knowledge/btc-map.d.ts +87 -0
  22. package/dist/knowledge/btc-map.d.ts.map +1 -0
  23. package/dist/knowledge/btc-map.js +365 -0
  24. package/dist/knowledge/btc-map.js.map +1 -0
  25. package/dist/lsps1/contract.d.ts +55 -0
  26. package/dist/lsps1/contract.d.ts.map +1 -0
  27. package/dist/lsps1/contract.js +91 -0
  28. package/dist/lsps1/contract.js.map +1 -0
  29. package/dist/memory/store.d.ts +7 -1
  30. package/dist/memory/store.d.ts.map +1 -1
  31. package/dist/memory/store.js +43 -3
  32. package/dist/memory/store.js.map +1 -1
  33. package/dist/memory/types.d.ts +12 -0
  34. package/dist/memory/types.d.ts.map +1 -1
  35. package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
  36. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
  37. package/dist/recipe/kaleidoswap-atomic.js +111 -0
  38. package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
  39. package/dist/recipe/runner.d.ts.map +1 -1
  40. package/dist/recipe/runner.js +13 -1
  41. package/dist/recipe/runner.js.map +1 -1
  42. package/dist/skills/registry.d.ts.map +1 -1
  43. package/dist/skills/registry.js +20 -2
  44. package/dist/skills/registry.js.map +1 -1
  45. package/dist/wallet/confirm.d.ts +12 -0
  46. package/dist/wallet/confirm.d.ts.map +1 -0
  47. package/dist/wallet/confirm.js +67 -0
  48. package/dist/wallet/confirm.js.map +1 -0
  49. package/package.json +2 -1
  50. package/skills/README.md +6 -1
  51. package/skills/kaleido-lsps/SKILL.md +56 -0
  52. package/skills/kaleido-trading/SKILL.md +85 -18
  53. package/skills/merchant-finder/SKILL.md +87 -0
  54. package/skills/paid-data/SKILL.md +12 -0
  55. package/skills/wallet-assistant/SKILL.md +38 -0
  56. package/src/capabilities.ts +12 -0
  57. package/src/context/context.test.ts +6 -2
  58. package/src/engine.ts +6 -0
  59. package/src/funnel.ts +32 -7
  60. package/src/index.ts +43 -0
  61. package/src/kaleidoswap/contract.test.ts +147 -0
  62. package/src/kaleidoswap/contract.ts +212 -0
  63. package/src/knowledge/btc-map.test.ts +188 -0
  64. package/src/knowledge/btc-map.ts +446 -0
  65. package/src/lsps1/contract.test.ts +81 -0
  66. package/src/lsps1/contract.ts +132 -0
  67. package/src/memory/memory.test.ts +55 -0
  68. package/src/memory/store.ts +49 -4
  69. package/src/memory/types.ts +13 -0
  70. package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
  71. package/src/recipe/kaleidoswap-atomic.ts +117 -0
  72. package/src/recipe/runner.ts +13 -1
  73. package/src/skills/registry.ts +21 -2
  74. package/src/skills/skills.test.ts +42 -0
  75. package/src/wallet/confirm.test.ts +57 -0
  76. package/src/wallet/confirm.ts +74 -0
  77. package/skills/kaleido-wallet/SKILL.md +0 -28
@@ -0,0 +1,446 @@
1
+ /**
2
+ * BTC Map tool source — exposes `find_merchant_locations` and
3
+ * `get_merchant_info` so the agent can answer "where can I spend Bitcoin
4
+ * near me?" using the SAME tool names on every surface.
5
+ *
6
+ * - mobile → injects device GPS + a live BTC Map fetch
7
+ * - desktop → injects a live fetch (or a server-side cache)
8
+ * - eval / playground → no injection, falls back to the bundled offline list
9
+ *
10
+ * Pure data + orchestration — NO network in core. The host injects the
11
+ * `location` resolver and the `fetch` adapter; without them the source still
12
+ * runs against an offline `Merchant[]` so the skill is never dead on arrival.
13
+ *
14
+ * The result shape mirrors what rate's host returns today, so a mobile host
15
+ * can swap its bespoke merchant tools for this factory verbatim.
16
+ */
17
+
18
+ import type { ToolDef } from '../types.js';
19
+ import type { ToolSource } from '../tools/source.js';
20
+ import type { Merchant } from './merchants.js';
21
+
22
+ const FIND = 'find_merchant_locations';
23
+ const INFO = 'get_merchant_info';
24
+
25
+ /** A geographic point + optional human label. */
26
+ export interface LatLng {
27
+ lat: number;
28
+ lng: number;
29
+ /** Optional "Lugano, Switzerland" style label for messages. */
30
+ label?: string;
31
+ /** True when the location came from the device GPS, false for a default/fallback. */
32
+ precise?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Host-injected location resolver. Core is platform-agnostic — RN has GPS,
37
+ * Node typically doesn't. When omitted, `find_merchant_locations` falls back
38
+ * to the offline merchant list.
39
+ */
40
+ export interface LocationProvider {
41
+ /** Resolve "near me" — the device location, or null if unavailable. */
42
+ getCurrent(): Promise<LatLng | null>;
43
+ /** Optional: geocode a free-text address ("Via Pessina 12, Lugano"). */
44
+ geocode?(address: string): Promise<LatLng | null>;
45
+ }
46
+
47
+ /** A merchant returned by a live BTC Map fetch. */
48
+ export interface BtcMapMerchant extends Merchant {
49
+ /** Distance from the search centre, metres. */
50
+ distance_m?: number;
51
+ /** Free-text contact extras the model can surface to the user. */
52
+ phone?: string;
53
+ website?: string;
54
+ opening_hours?: string;
55
+ }
56
+
57
+ /**
58
+ * Host-injected live-fetch adapter. Receives a normalized query and returns
59
+ * the matching merchants. The host is responsible for the HTTP call, caching,
60
+ * and any rate limits — core never reaches the network.
61
+ */
62
+ export type BtcMapFetch = (q: {
63
+ center: { lat: number; lng: number };
64
+ radiusMeters: number;
65
+ query?: string;
66
+ category?: string;
67
+ limit: number;
68
+ }) => Promise<BtcMapMerchant[]>;
69
+
70
+ export interface BtcMapToolOptions {
71
+ /** Resolve "near me" + geocode an address. RN hosts inject Expo Location. */
72
+ location?: LocationProvider;
73
+ /** Hit a live BTC Map (or your own cache). Host-injected — no network in core. */
74
+ fetch?: BtcMapFetch;
75
+ /** Offline list used when no location/fetch is available. Defaults to BTC_MAP_SAMPLE. */
76
+ offlineMerchants?: Merchant[];
77
+ /** Default for `limit` when the caller doesn't set one. Default 10. */
78
+ k?: number;
79
+ }
80
+
81
+ /** A tiny, hand-curated sample so the skill works offline out of the box. */
82
+ export const BTC_MAP_SAMPLE: Merchant[] = [
83
+ {
84
+ id: 'lugano-bitcoinpeople-cafe',
85
+ name: 'Bitcoin People Café',
86
+ category: 'cafe',
87
+ address: 'Via Pessina 12',
88
+ city: 'Lugano',
89
+ lat: 46.0037,
90
+ lng: 8.9511,
91
+ acceptedAssets: ['lightning', 'onchain'],
92
+ description: 'Specialty espresso bar; Plan ₿ Lugano partner.',
93
+ },
94
+ {
95
+ id: 'lugano-bistro-libertine',
96
+ name: 'Bistro Libertine',
97
+ category: 'restaurant',
98
+ address: 'Piazza della Riforma 3',
99
+ city: 'Lugano',
100
+ lat: 46.004,
101
+ lng: 8.952,
102
+ acceptedAssets: ['lightning', 'usdt', 'onchain'],
103
+ description: 'Italian-Swiss bistro, lunch + dinner. Accepts Tether on Liquid.',
104
+ },
105
+ {
106
+ id: 'lugano-bookshop-volta',
107
+ name: 'Libreria Volta',
108
+ category: 'shop',
109
+ address: 'Via Cattedrale 8',
110
+ city: 'Lugano',
111
+ lat: 46.0055,
112
+ lng: 8.9499,
113
+ acceptedAssets: ['lightning'],
114
+ description: 'Independent bookshop; Italian, English and German titles.',
115
+ },
116
+ {
117
+ id: 'lisbon-meson-andaluz',
118
+ name: 'Mesón Andaluz',
119
+ category: 'restaurant',
120
+ address: 'Rua das Flores 42',
121
+ city: 'Lisbon',
122
+ lat: 38.71,
123
+ lng: -9.143,
124
+ acceptedAssets: ['lightning', 'onchain'],
125
+ description: 'Andalusian tapas in Chiado. Bitcoin accepted since 2022.',
126
+ },
127
+ {
128
+ id: 'lisbon-surf-bitcoin',
129
+ name: 'Surf & Sats',
130
+ category: 'shop',
131
+ address: 'Av. da Liberdade 180',
132
+ city: 'Lisbon',
133
+ lat: 38.7211,
134
+ lng: -9.1466,
135
+ acceptedAssets: ['lightning'],
136
+ description: 'Surfboard rental and lessons in Costa da Caparica.',
137
+ },
138
+ {
139
+ id: 'sansalvador-elzonte-hope',
140
+ name: 'Hope House El Zonte',
141
+ category: 'cafe',
142
+ address: 'Calle Principal',
143
+ city: 'El Zonte',
144
+ lat: 13.492,
145
+ lng: -89.4395,
146
+ acceptedAssets: ['lightning', 'onchain'],
147
+ description: 'Bitcoin Beach hub. Coffee, community, and a Lightning ATM.',
148
+ },
149
+ {
150
+ id: 'sansalvador-elzonte-garten',
151
+ name: 'Garten Restaurante',
152
+ category: 'restaurant',
153
+ address: 'Bitcoin Beach',
154
+ city: 'El Zonte',
155
+ lat: 13.4925,
156
+ lng: -89.438,
157
+ acceptedAssets: ['lightning'],
158
+ description: 'Beachfront restaurant, full Bitcoin payments since 2021.',
159
+ },
160
+ {
161
+ id: 'nyc-pubkey',
162
+ name: 'PubKey',
163
+ category: 'bar',
164
+ address: '85 Washington Pl',
165
+ city: 'New York',
166
+ lat: 40.732,
167
+ lng: -73.999,
168
+ acceptedAssets: ['lightning', 'onchain'],
169
+ description: 'Bitcoin bar in Greenwich Village — meetups, Lightning tap.',
170
+ },
171
+ {
172
+ id: 'prague-paralelni-polis',
173
+ name: 'Paralelní Polis',
174
+ category: 'cafe',
175
+ address: 'Dělnická 43',
176
+ city: 'Prague',
177
+ lat: 50.105,
178
+ lng: 14.448,
179
+ acceptedAssets: ['lightning', 'onchain', 'monero'],
180
+ description: 'Crypto-only café and hackerspace — no fiat accepted, ever.',
181
+ },
182
+ {
183
+ id: 'amsterdam-bitcoin-embassy',
184
+ name: 'Bitcoin Embassy Amsterdam',
185
+ category: 'cafe',
186
+ address: 'Nieuwezijds Voorburgwal 162',
187
+ city: 'Amsterdam',
188
+ lat: 52.374,
189
+ lng: 4.893,
190
+ acceptedAssets: ['lightning', 'onchain'],
191
+ description: 'Co-working café and meetup hub, Lightning tap on draft beer.',
192
+ },
193
+ ];
194
+
195
+ /** Clamp a value to a numeric range, returning the default when input is bad. */
196
+ function clamp(n: unknown, lo: number, hi: number, dflt: number): number {
197
+ const v = Number(n);
198
+ if (!Number.isFinite(v)) return dflt;
199
+ return Math.min(hi, Math.max(lo, v));
200
+ }
201
+
202
+ /** Substring scoring used by the offline fallback (matches the old behavior). */
203
+ function fuzzyScore(query: string, text: string): number {
204
+ const q = query.toLowerCase();
205
+ const t = text.toLowerCase();
206
+ if (!q) return 1;
207
+ if (t.includes(q)) return 1;
208
+ let hits = 0;
209
+ let qi = 0;
210
+ for (let i = 0; i < t.length && qi < q.length; i++) {
211
+ if (t[i] === q[qi]) {
212
+ hits++;
213
+ qi++;
214
+ }
215
+ }
216
+ return hits / q.length;
217
+ }
218
+
219
+ /** Map a Merchant → the response row the model sees (stable for rate parity). */
220
+ function row(m: BtcMapMerchant) {
221
+ return {
222
+ id: m.id,
223
+ name: m.name,
224
+ address: m.address,
225
+ category: m.category,
226
+ lat: m.lat,
227
+ lng: m.lng,
228
+ distance_m: m.distance_m,
229
+ phone: m.phone,
230
+ website: m.website,
231
+ opening_hours: m.opening_hours,
232
+ accepts_bitcoin: m.acceptedAssets?.includes('onchain') ?? true,
233
+ accepts_lightning: m.acceptedAssets?.includes('lightning') ?? true,
234
+ };
235
+ }
236
+
237
+ /** Offline search: substring + optional category over the bundled list. */
238
+ function searchOffline(
239
+ merchants: Merchant[],
240
+ query: string | undefined,
241
+ category: string | undefined,
242
+ limit: number,
243
+ ): Merchant[] {
244
+ let filtered = merchants;
245
+ if (category) {
246
+ const c = category.toLowerCase().trim();
247
+ filtered = filtered.filter((m) => (m.category ?? '').toLowerCase() === c);
248
+ }
249
+ const q = (query ?? '').trim();
250
+ if (q.length >= 2) {
251
+ filtered = filtered
252
+ .map((m) => {
253
+ const hay = `${m.name ?? ''} ${m.description ?? ''} ${m.address ?? ''} ${m.city ?? ''}`;
254
+ const score =
255
+ fuzzyScore(q, m.name ?? '') * 3 +
256
+ fuzzyScore(q, m.address ?? '') * 2 +
257
+ fuzzyScore(q, hay) * 1;
258
+ return { m, score };
259
+ })
260
+ .filter((x) => x.score > 0.3)
261
+ .sort((a, b) => b.score - a.score)
262
+ .map((x) => x.m);
263
+ }
264
+ return filtered.slice(0, limit);
265
+ }
266
+
267
+ /**
268
+ * Build a ToolSource exposing `find_merchant_locations` + `get_merchant_info`.
269
+ *
270
+ * Resolution order for `find_merchant_locations`:
271
+ * 1. `near_address` provided → opts.location.geocode → opts.fetch (live)
272
+ * 2. opts.location.getCurrent → opts.fetch (live)
273
+ * 3. fall through to offline substring search over opts.offlineMerchants
274
+ *
275
+ * Any step that fails silently falls through to the next, so the skill is
276
+ * always answerable.
277
+ */
278
+ export function createBtcMapToolSource(opts: BtcMapToolOptions = {}): ToolSource {
279
+ const offline = opts.offlineMerchants ?? BTC_MAP_SAMPLE;
280
+ const defaultLimit = opts.k ?? 10;
281
+
282
+ const find: ToolDef = {
283
+ name: FIND,
284
+ description:
285
+ "Find Bitcoin-accepting merchants near the user using live BTC Map data " +
286
+ "and the device's real location when available. Use when the user wants " +
287
+ 'merchants, shops, restaurants, cafes, bars, ATMs, or places to spend ' +
288
+ 'Bitcoin nearby. Pass ONLY the fields the user actually named — do not ' +
289
+ 'invent constraints (e.g. omit `query` when they just say "near me").',
290
+ parameters: {
291
+ type: 'object',
292
+ properties: {
293
+ query: { type: 'string', description: 'Optional filter for merchant name or type, e.g. "coffee"' },
294
+ category: { type: 'string', description: 'restaurant | cafe | bar | shop | grocery | lodging | atm' },
295
+ near_address: { type: 'string', description: 'Address/city to search around instead of the current location' },
296
+ radius_km: { type: 'number', description: 'Search radius in km (0.25–50, default 5)' },
297
+ limit: { type: 'number', description: 'Max number of results (1–20, default 10)' },
298
+ },
299
+ },
300
+ };
301
+
302
+ const info: ToolDef = {
303
+ name: INFO,
304
+ description:
305
+ 'Get detailed information about one specific merchant by id or name — ' +
306
+ 'full address, accepted assets, contact details. Use after ' +
307
+ 'find_merchant_locations when the user asks for more on a specific result.',
308
+ parameters: {
309
+ type: 'object',
310
+ properties: {
311
+ merchant_id: { type: 'string', description: 'Merchant id from find_merchant_locations (string or number)' },
312
+ merchant_name: { type: 'string', description: 'Merchant name (used when id is unknown)' },
313
+ },
314
+ },
315
+ };
316
+
317
+ async function tryLive(
318
+ center: LatLng,
319
+ radiusMeters: number,
320
+ query: string | undefined,
321
+ category: string | undefined,
322
+ limit: number,
323
+ ): Promise<BtcMapMerchant[] | null> {
324
+ if (!opts.fetch) return null;
325
+ try {
326
+ return await opts.fetch({
327
+ center: { lat: center.lat, lng: center.lng },
328
+ radiusMeters,
329
+ query,
330
+ category,
331
+ limit,
332
+ });
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ async function resolveCenter(near_address: string | undefined): Promise<LatLng | null> {
339
+ if (!opts.location) return null;
340
+ if (near_address && near_address.trim().length >= 2 && opts.location.geocode) {
341
+ try {
342
+ const pt = await opts.location.geocode(near_address);
343
+ if (pt) return { ...pt, label: near_address, precise: false };
344
+ } catch {
345
+ /* fall through */
346
+ }
347
+ }
348
+ try {
349
+ return await opts.location.getCurrent();
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ async function findLocations(args: Record<string, unknown>): Promise<unknown> {
356
+ const query = args.query ? String(args.query) : undefined;
357
+ const category = args.category ? String(args.category) : undefined;
358
+ const near_address = args.near_address ? String(args.near_address) : undefined;
359
+ const radius_km = clamp(args.radius_km, 0.25, 50, 5);
360
+ const limit = clamp(args.limit, 1, 20, defaultLimit);
361
+ const radiusMeters = radius_km * 1000;
362
+
363
+ // 1 + 2. Try the live path when we can.
364
+ const center = await resolveCenter(near_address);
365
+ if (center) {
366
+ const live = await tryLive(center, radiusMeters, query, category, limit);
367
+ if (live) {
368
+ const where = center.label || (center.precise ? 'your location' : 'the default location');
369
+ return {
370
+ success: true,
371
+ source: 'btcmap',
372
+ precise_location: !!center.precise,
373
+ center: { lat: center.lat, lng: center.lng },
374
+ merchants: live.map(row),
375
+ total_found: live.length,
376
+ message:
377
+ live.length > 0
378
+ ? `Found ${live.length} Bitcoin merchant${live.length === 1 ? '' : 's'} near ${where}${query ? ` matching "${query}"` : ''}.`
379
+ : `No Bitcoin merchants found within ${radius_km} km of ${where}. Try widening the radius.`,
380
+ };
381
+ }
382
+ }
383
+
384
+ // 3. Offline fallback.
385
+ const found = searchOffline(offline, query, category, limit);
386
+ return {
387
+ success: true,
388
+ source: 'offline',
389
+ precise_location: false,
390
+ merchants: found.map(row),
391
+ total_found: found.length,
392
+ message:
393
+ found.length > 0
394
+ ? `Showing ${found.length} merchant${found.length === 1 ? '' : 's'} from the offline list${query ? ` matching "${query}"` : ''}.`
395
+ : `No merchants in the offline list matched${query ? ` "${query}"` : ''}. The host hasn't injected a live BTC Map fetch.`,
396
+ };
397
+ }
398
+
399
+ async function getInfo(args: Record<string, unknown>): Promise<unknown> {
400
+ const idArg = args.merchant_id;
401
+ const nameArg = args.merchant_name ? String(args.merchant_name).trim() : '';
402
+ let m: Merchant | undefined;
403
+ if (idArg !== undefined && idArg !== null && idArg !== '') {
404
+ const want = String(idArg);
405
+ m = offline.find((x) => String(x.id) === want);
406
+ }
407
+ if (!m && nameArg.length >= 2) {
408
+ const q = nameArg.toLowerCase();
409
+ m =
410
+ offline.find((x) => (x.name ?? '').toLowerCase() === q) ??
411
+ offline
412
+ .map((x) => ({ x, score: fuzzyScore(q, (x.name ?? '').toLowerCase()) }))
413
+ .filter((r) => r.score > 0.5)
414
+ .sort((a, b) => b.score - a.score)[0]?.x;
415
+ }
416
+ if (!m) {
417
+ const suggestions = nameArg
418
+ ? offline
419
+ .map((x) => ({ name: x.name ?? '', score: fuzzyScore(nameArg.toLowerCase(), (x.name ?? '').toLowerCase()) }))
420
+ .filter((r) => r.score > 0.3)
421
+ .sort((a, b) => b.score - a.score)
422
+ .slice(0, 3)
423
+ .map((r) => r.name)
424
+ : [];
425
+ return {
426
+ success: false,
427
+ error: `Could not find merchant${nameArg ? ` "${nameArg}"` : idArg !== undefined ? ` with id ${idArg}` : ''}.`,
428
+ suggestions: suggestions.length ? suggestions : undefined,
429
+ };
430
+ }
431
+ return { success: true, merchant: { ...row(m as BtcMapMerchant), city: m.city } };
432
+ }
433
+
434
+ async function execute(name: string, args: Record<string, unknown>): Promise<unknown> {
435
+ if (name === FIND) return findLocations(args);
436
+ if (name === INFO) return getInfo(args);
437
+ throw new Error(`btc-map: unknown tool "${name}"`);
438
+ }
439
+
440
+ return {
441
+ id: 'btc-map',
442
+ listTools: () => [find, info],
443
+ has: (name) => name === FIND || name === INFO,
444
+ execute,
445
+ };
446
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ LSPS1_TOOLS,
4
+ LSPS1_SPEND_TOOLS,
5
+ isLsps1SpendTool,
6
+ getLsps1Tool,
7
+ bindLsps1Tools,
8
+ type Lsps1Handler,
9
+ } from './contract.js';
10
+
11
+ describe('LSPS1_TOOLS — shape invariants', () => {
12
+ it('exposes the expected tool names', () => {
13
+ expect(LSPS1_TOOLS.map((t) => t.name)).toEqual([
14
+ 'lsp_get_info',
15
+ 'lsp_get_network_info',
16
+ 'lsp_estimate_fees',
17
+ 'lsp_create_order',
18
+ 'lsp_get_order',
19
+ ]);
20
+ });
21
+
22
+ it('every tool has an object parameters schema', () => {
23
+ for (const t of LSPS1_TOOLS) {
24
+ expect((t.parameters as any)?.type).toBe('object');
25
+ }
26
+ });
27
+
28
+ it('aligns spend ↔ requiresConfirmation', () => {
29
+ for (const t of LSPS1_TOOLS) {
30
+ expect(!!t.spend).toBe(!!t.requiresConfirmation);
31
+ }
32
+ });
33
+
34
+ it('marks only lsp_create_order as spend', () => {
35
+ expect([...LSPS1_SPEND_TOOLS]).toEqual(['lsp_create_order']);
36
+ expect(isLsps1SpendTool('lsp_create_order')).toBe(true);
37
+ expect(isLsps1SpendTool('lsp_get_info')).toBe(false);
38
+ });
39
+
40
+ it('getLsps1Tool returns by name', () => {
41
+ expect(getLsps1Tool('lsp_estimate_fees')?.name).toBe('lsp_estimate_fees');
42
+ expect(getLsps1Tool('nope')).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe('bindLsps1Tools', () => {
47
+ const echoHandlers = (): Record<string, Lsps1Handler> => ({
48
+ lsp_get_info: async () => ({ ok: true, t: 'get_info' }),
49
+ lsp_get_network_info: async () => ({ ok: true, t: 'get_network_info' }),
50
+ lsp_estimate_fees: async (a) => ({ ok: true, t: 'estimate_fees', args: a }),
51
+ lsp_create_order: async (a) => ({ ok: true, t: 'create_order', args: a }),
52
+ lsp_get_order: async (a) => ({ ok: true, t: 'get_order', args: a }),
53
+ });
54
+
55
+ it('binds every tool and preserves the spend gate', () => {
56
+ const src = bindLsps1Tools(echoHandlers());
57
+ expect(src.listTools().length).toBe(5);
58
+ const create = src.listTools().find((t) => t.name === 'lsp_create_order');
59
+ expect(create?.requiresConfirmation).toBe(true);
60
+ const info = src.listTools().find((t) => t.name === 'lsp_get_info');
61
+ expect(info?.requiresConfirmation).toBeFalsy();
62
+ });
63
+
64
+ it('dispatches with args', async () => {
65
+ const src = bindLsps1Tools(echoHandlers());
66
+ const r = await src.execute('lsp_estimate_fees', { lsp_balance_sat: 500_000 });
67
+ expect(r).toMatchObject({ ok: true, t: 'estimate_fees', args: { lsp_balance_sat: 500_000 } });
68
+ });
69
+
70
+ it('throws on a missing handler unless allowMissing', () => {
71
+ const partial = { lsp_get_info: echoHandlers().lsp_get_info };
72
+ expect(() => bindLsps1Tools(partial)).toThrow(/no handler/);
73
+ const src = bindLsps1Tools(partial, { allowMissing: true });
74
+ expect(src.listTools().map((t) => t.name)).toEqual(['lsp_get_info']);
75
+ });
76
+
77
+ it('uses opts.id for the ToolSource id', () => {
78
+ const src = bindLsps1Tools(echoHandlers(), { id: 'lsp-prod' });
79
+ expect(src.id).toBe('lsp-prod');
80
+ });
81
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Canonical LSPS1 tool contract — Lightning Service Provider channel orders.
3
+ *
4
+ * LSPS1 is a transport-agnostic protocol for buying inbound channel liquidity
5
+ * from a Lightning Service Provider. The maker happens to implement it, but a
6
+ * different LSP could too — so the tool names are LSP-agnostic (`lsp_*`),
7
+ * not `kaleidoswap_*`. The host's binder decides which LSP they reach.
8
+ *
9
+ * Every surface implements THESE EXACT tools, only the transport differs:
10
+ * - mobile → in-process handlers over the WDK LSP adapter
11
+ * - desktop → HTTP / MCP / CLI handlers
12
+ * - eval → stub handlers
13
+ *
14
+ * `lsp_create_order` is a spend → confirmation-gated.
15
+ *
16
+ * Pure data — no deps, no fetch, RN-safe.
17
+ */
18
+
19
+ import type { ToolDef } from '../types.js';
20
+ import { InProcessToolSource } from '../tools/in-process.js';
21
+ import type { InProcessTool } from '../tools/in-process.js';
22
+
23
+ export interface Lsps1ToolDef extends ToolDef {
24
+ /** Moves funds → confirmation-gated. */
25
+ spend?: boolean;
26
+ }
27
+
28
+ type Props = Record<string, { type: string; description?: string; enum?: string[] }>;
29
+
30
+ function t(name: string, description: string, properties: Props = {}, required: string[] = [], spend = false): Lsps1ToolDef {
31
+ return {
32
+ name,
33
+ description,
34
+ spend,
35
+ requiresConfirmation: spend,
36
+ parameters: { type: 'object', properties, required },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * The canonical LSPS1 tool list — agent-facing schemas. Each host's binder
42
+ * translates these args into the LSP's request body (LSPS1 JSON-RPC, the
43
+ * KaleidoSwap maker's REST routes, MCP, or a WDK adapter call).
44
+ */
45
+ export const LSPS1_TOOLS: Lsps1ToolDef[] = [
46
+ t('lsp_get_info',
47
+ "Get the LSP's capabilities: minimum/maximum channel size, supported expiries, fee structure, accepted payment options. Use this before estimating or ordering a channel. No args."),
48
+
49
+ t('lsp_get_network_info',
50
+ "Get the LSP's Lightning network info: pubkey, host, port, connect URI. Useful to display the counterparty or pre-connect a peer. No args."),
51
+
52
+ t('lsp_estimate_fees',
53
+ "Estimate the fee for a channel order BEFORE committing. Returns the total cost in sats plus any LSP routing fee. Re-estimate rather than reusing a stale value.",
54
+ {
55
+ lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
56
+ client_balance_sat: { type: 'number', description: "Sats the user pre-funds into the channel (push amount). Often 0." },
57
+ channel_expiry_blocks: { type: 'number', description: 'Optional minimum lease in blocks. Defaults to the LSP minimum.' },
58
+ },
59
+ ['lsp_balance_sat']),
60
+
61
+ t('lsp_create_order',
62
+ "Create a channel order. SPEND: confirmation-gated. Returns an order id + a Lightning invoice the user pays to lock the order. The channel opens only after payment.",
63
+ {
64
+ lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
65
+ client_balance_sat: { type: 'number', description: 'Sats the user pre-funds. Often 0.' },
66
+ channel_expiry_blocks: { type: 'number', description: 'Minimum lease in blocks. Defaults to LSP minimum from lsp_get_info.' },
67
+ refund_onchain_address: { type: 'string', description: 'Optional on-chain refund address if the LSP cannot open the channel.' },
68
+ },
69
+ ['lsp_balance_sat'],
70
+ /* spend */ true),
71
+
72
+ t('lsp_get_order',
73
+ 'Check the status of an LSPS1 order — pending / paid / opening / completed / failed. Poll after creating an order until the channel opens.',
74
+ {
75
+ order_id: { type: 'string', description: 'The order id from lsp_create_order.' },
76
+ },
77
+ ['order_id']),
78
+ ];
79
+
80
+ /** All LSPS1 tool names that move funds (confirmation-gated). */
81
+ export const LSPS1_SPEND_TOOLS: Set<string> = new Set(
82
+ LSPS1_TOOLS.filter((t) => t.spend).map((t) => t.name),
83
+ );
84
+
85
+ export function isLsps1SpendTool(name: string): boolean {
86
+ return LSPS1_SPEND_TOOLS.has(name);
87
+ }
88
+
89
+ export function getLsps1Tool(name: string): Lsps1ToolDef | undefined {
90
+ return LSPS1_TOOLS.find((t) => t.name === name);
91
+ }
92
+
93
+ /** A handler bound to one LSPS1 tool. */
94
+ export type Lsps1Handler = (args: Record<string, unknown>) => Promise<unknown>;
95
+
96
+ export interface BindLsps1Options {
97
+ /** Skip tools without a handler instead of throwing (default false). */
98
+ allowMissing?: boolean;
99
+ /** ToolSource id for the registry (default 'lsps1'). */
100
+ id?: string;
101
+ }
102
+
103
+ /**
104
+ * Bind LSPS1 contract tools to in-process handlers → an InProcessToolSource.
105
+ *
106
+ * const source = bindLsps1Tools({
107
+ * lsp_get_info: async () => makerLsp.getInfo(),
108
+ * lsp_estimate_fees: async (args) => makerLsp.estimateFees(args),
109
+ * lsp_create_order: async (args) => makerLsp.createOrder(args),
110
+ * lsp_get_order: async ({ order_id }) => makerLsp.getOrder(order_id),
111
+ * lsp_get_network_info:async () => makerLsp.networkInfo(),
112
+ * });
113
+ * tools.register(source);
114
+ */
115
+ export function bindLsps1Tools(handlers: Record<string, Lsps1Handler>, opts: BindLsps1Options = {}): InProcessToolSource {
116
+ const bound: InProcessTool[] = [];
117
+ for (const def of LSPS1_TOOLS) {
118
+ const handler = handlers[def.name];
119
+ if (!handler) {
120
+ if (opts.allowMissing) continue;
121
+ throw new Error(`bindLsps1Tools: no handler for "${def.name}"`);
122
+ }
123
+ bound.push({
124
+ name: def.name,
125
+ description: def.description,
126
+ parameters: def.parameters,
127
+ requiresConfirmation: def.requiresConfirmation,
128
+ handler,
129
+ });
130
+ }
131
+ return new InProcessToolSource(opts.id ?? 'lsps1', bound);
132
+ }