@kaleidorg/mind 0.4.0 → 0.5.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 (89) hide show
  1. package/dist/funnel.d.ts +19 -0
  2. package/dist/funnel.d.ts.map +1 -1
  3. package/dist/funnel.js +48 -10
  4. package/dist/funnel.js.map +1 -1
  5. package/dist/index.d.ts +5 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/kaleidoswap/contract.d.ts +3 -3
  10. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  11. package/dist/kaleidoswap/contract.js +16 -4
  12. package/dist/kaleidoswap/contract.js.map +1 -1
  13. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  14. package/dist/knowledge/bitcoin-copilot.js +102 -0
  15. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  16. package/dist/knowledge/btc-map.d.ts +14 -17
  17. package/dist/knowledge/btc-map.d.ts.map +1 -1
  18. package/dist/knowledge/btc-map.js +66 -266
  19. package/dist/knowledge/btc-map.js.map +1 -1
  20. package/dist/lsps1/contract.d.ts.map +1 -1
  21. package/dist/lsps1/contract.js +28 -10
  22. package/dist/lsps1/contract.js.map +1 -1
  23. package/dist/qvac/parse.d.ts +15 -0
  24. package/dist/qvac/parse.d.ts.map +1 -1
  25. package/dist/qvac/parse.js +68 -5
  26. package/dist/qvac/parse.js.map +1 -1
  27. package/dist/qvac/text.d.ts.map +1 -1
  28. package/dist/qvac/text.js +4 -0
  29. package/dist/qvac/text.js.map +1 -1
  30. package/dist/recipe/buy-asset-channel.d.ts +26 -0
  31. package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
  32. package/dist/recipe/buy-asset-channel.js +112 -0
  33. package/dist/recipe/buy-asset-channel.js.map +1 -0
  34. package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
  35. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  36. package/dist/recipe/kaleidoswap-atomic.js +101 -63
  37. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  38. package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
  39. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
  40. package/dist/recipe/kaleidoswap-channel-order.js +493 -0
  41. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
  42. package/dist/recipe/kaleidoswap-price.d.ts +21 -0
  43. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
  44. package/dist/recipe/kaleidoswap-price.js +57 -0
  45. package/dist/recipe/kaleidoswap-price.js.map +1 -0
  46. package/dist/recipe/runner.d.ts +7 -1
  47. package/dist/recipe/runner.d.ts.map +1 -1
  48. package/dist/recipe/runner.js +115 -29
  49. package/dist/recipe/runner.js.map +1 -1
  50. package/dist/recipe/swap.d.ts +26 -1
  51. package/dist/recipe/swap.d.ts.map +1 -1
  52. package/dist/recipe/swap.js +108 -13
  53. package/dist/recipe/swap.js.map +1 -1
  54. package/dist/recipe/types.d.ts +25 -1
  55. package/dist/recipe/types.d.ts.map +1 -1
  56. package/dist/skills/registry.d.ts +33 -1
  57. package/dist/skills/registry.d.ts.map +1 -1
  58. package/dist/skills/registry.js +45 -1
  59. package/dist/skills/registry.js.map +1 -1
  60. package/package.json +1 -1
  61. package/skills/README.md +3 -0
  62. package/skills/kaleido-lsps/SKILL.md +101 -43
  63. package/skills/kaleido-trading/SKILL.md +81 -31
  64. package/skills/merchant-finder/SKILL.md +96 -66
  65. package/skills/rgb-lightning-node/SKILL.md +108 -0
  66. package/skills/wallet-assistant/SKILL.md +32 -21
  67. package/src/funnel.ts +66 -11
  68. package/src/index.ts +14 -2
  69. package/src/kaleidoswap/contract.test.ts +7 -2
  70. package/src/kaleidoswap/contract.ts +27 -5
  71. package/src/knowledge/bitcoin-copilot.ts +111 -0
  72. package/src/knowledge/btc-map.test.ts +53 -96
  73. package/src/knowledge/btc-map.ts +72 -287
  74. package/src/lsps1/contract.ts +32 -14
  75. package/src/qvac/parse.test.ts +70 -1
  76. package/src/qvac/parse.ts +71 -5
  77. package/src/qvac/text.ts +4 -0
  78. package/src/recipe/buy-asset-channel.test.ts +148 -0
  79. package/src/recipe/buy-asset-channel.ts +118 -0
  80. package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
  81. package/src/recipe/kaleidoswap-atomic.ts +112 -66
  82. package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
  83. package/src/recipe/kaleidoswap-channel-order.ts +548 -0
  84. package/src/recipe/kaleidoswap-price.ts +68 -0
  85. package/src/recipe/recipe.test.ts +61 -5
  86. package/src/recipe/runner.ts +128 -31
  87. package/src/recipe/swap.ts +109 -13
  88. package/src/recipe/types.ts +25 -1
  89. package/src/skills/registry.ts +52 -1
@@ -1,12 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  createBtcMapToolSource,
4
- BTC_MAP_SAMPLE,
5
4
  type BtcMapFetch,
6
5
  type LocationProvider,
7
6
  type BtcMapMerchant,
8
7
  } from './btc-map.js';
9
- import type { Merchant } from './merchants.js';
10
8
 
11
9
  const exec = async (
12
10
  src: ReturnType<typeof createBtcMapToolSource>,
@@ -15,13 +13,13 @@ const exec = async (
15
13
  ): Promise<any> => src.execute(name, args);
16
14
 
17
15
  describe('createBtcMapToolSource — tool surface', () => {
18
- it('exposes find_merchant_locations and get_merchant_info', () => {
16
+ it('exposes ONLY find_merchant_locations (no get_merchant_info, no legacy name)', () => {
19
17
  const src = createBtcMapToolSource();
20
18
  const names = src.listTools().map((t) => t.name);
21
- expect(names).toEqual(['find_merchant_locations', 'get_merchant_info']);
19
+ expect(names).toEqual(['find_merchant_locations']);
22
20
  expect(src.has('find_merchant_locations')).toBe(true);
23
- expect(src.has('get_merchant_info')).toBe(true);
24
- expect(src.has('find_merchants')).toBe(false); // legacy name is gone
21
+ expect(src.has('get_merchant_info')).toBe(false);
22
+ expect(src.has('find_merchants')).toBe(false);
25
23
  });
26
24
 
27
25
  it('throws on unknown tool', async () => {
@@ -30,51 +28,52 @@ describe('createBtcMapToolSource — tool surface', () => {
30
28
  });
31
29
  });
32
30
 
33
- describe('find_merchant_locations — offline fallback', () => {
34
- it('returns offline rows when no location and no fetch are injected', async () => {
31
+ describe('find_merchant_locations — no offline fallback', () => {
32
+ it('returns a clean error when no fetch adapter is injected', async () => {
35
33
  const src = createBtcMapToolSource();
36
34
  const r = await exec(src, 'find_merchant_locations', {});
37
- expect(r.success).toBe(true);
38
- expect(r.source).toBe('offline');
39
- expect(r.precise_location).toBe(false);
40
- expect(r.merchants.length).toBeGreaterThan(0);
41
- expect(r.merchants.length).toBeLessThanOrEqual(10); // default limit
42
- });
43
-
44
- it('honours category filter on offline data', async () => {
45
- const src = createBtcMapToolSource();
46
- const r = await exec(src, 'find_merchant_locations', { category: 'bar' });
47
- expect(r.merchants.every((m: any) => m.category === 'bar')).toBe(true);
48
- // Sample has at least one bar (PubKey).
49
- expect(r.merchants.length).toBeGreaterThan(0);
35
+ expect(r.success).toBe(false);
36
+ expect(typeof r.error).toBe('string');
37
+ expect(r.error).toMatch(/fetch adapter|unavailable/i);
38
+ expect(r.merchants).toBeUndefined();
50
39
  });
51
40
 
52
- it('honours query filter on offline data', async () => {
53
- const src = createBtcMapToolSource();
54
- const r = await exec(src, 'find_merchant_locations', { query: 'PubKey' });
55
- expect(r.merchants.some((m: any) => m.name === 'PubKey')).toBe(true);
41
+ it('returns a clean error when location cannot be resolved', async () => {
42
+ // Fetch is wired, but the location provider has no GPS and no geocoder.
43
+ const noLocation: LocationProvider = { getCurrent: async () => null };
44
+ const src = createBtcMapToolSource({
45
+ location: noLocation,
46
+ fetch: async () => [],
47
+ });
48
+ const r = await exec(src, 'find_merchant_locations', {});
49
+ expect(r.success).toBe(false);
50
+ expect(r.error).toMatch(/location/i);
56
51
  });
57
52
 
58
- it('clamps limit to 1–20 with a default of 10', async () => {
59
- const src = createBtcMapToolSource();
60
- const big = await exec(src, 'find_merchant_locations', { limit: 9999 });
61
- expect(big.merchants.length).toBeLessThanOrEqual(20);
62
- const tiny = await exec(src, 'find_merchant_locations', { limit: -5 });
63
- expect(tiny.merchants.length).toBeGreaterThanOrEqual(1); // clamped to ≥1
53
+ it('surfaces a fetch failure as a clean error (no fake fallback)', async () => {
54
+ const src = createBtcMapToolSource({
55
+ location: { getCurrent: async () => ({ lat: 46.0, lng: 8.95, precise: true }) },
56
+ fetch: async () => { throw new Error('btcmap is down'); },
57
+ });
58
+ const r = await exec(src, 'find_merchant_locations', {});
59
+ expect(r.success).toBe(false);
60
+ expect(r.error).toMatch(/btcmap is down/);
61
+ expect(r.merchants).toBeUndefined();
64
62
  });
65
63
 
66
- it('caller can override the offline dataset', async () => {
67
- const custom: Merchant[] = [
68
- { id: 'only', name: 'Only Café', category: 'cafe', acceptedAssets: ['lightning'] },
69
- ];
70
- const src = createBtcMapToolSource({ offlineMerchants: custom });
71
- const r = await exec(src, 'find_merchant_locations', {});
72
- expect(r.merchants).toHaveLength(1);
73
- expect(r.merchants[0].name).toBe('Only Café');
64
+ it('errors with the bad address when geocoding fails (no near_me fallback)', async () => {
65
+ const location: LocationProvider = {
66
+ getCurrent: async () => null,
67
+ geocode: async () => null,
68
+ };
69
+ const src = createBtcMapToolSource({ location, fetch: async () => [] });
70
+ const r = await exec(src, 'find_merchant_locations', { near_address: 'Nowhereistan' });
71
+ expect(r.success).toBe(false);
72
+ expect(r.error).toMatch(/Nowhereistan/);
74
73
  });
75
74
  });
76
75
 
77
- describe('find_merchant_locations — live path (host-injected fetch)', () => {
76
+ describe('find_merchant_locations — live path', () => {
78
77
  const point = { lat: 46.0, lng: 8.95 };
79
78
  const locationOnly: LocationProvider = {
80
79
  getCurrent: async () => ({ ...point, label: 'Lugano', precise: true }),
@@ -91,6 +90,7 @@ describe('find_merchant_locations — live path (host-injected fetch)', () => {
91
90
  };
92
91
  const src = createBtcMapToolSource({ location: locationOnly, fetch: fetchImpl });
93
92
  const r = await exec(src, 'find_merchant_locations', { query: 'café', radius_km: 3 });
93
+ expect(r.success).toBe(true);
94
94
  expect(r.source).toBe('btcmap');
95
95
  expect(r.precise_location).toBe(true);
96
96
  expect(r.merchants[0].name).toBe('Live Café');
@@ -119,70 +119,27 @@ describe('find_merchant_locations — live path (host-injected fetch)', () => {
119
119
  ];
120
120
  const src = createBtcMapToolSource({ location, fetch: fetchImpl });
121
121
  const r = await exec(src, 'find_merchant_locations', { near_address: 'Lisbon' });
122
+ expect(r.success).toBe(true);
122
123
  expect(seen).toEqual(['Lisbon']);
123
124
  expect(r.merchants[0].lat).toBeCloseTo(geocoded.lat);
124
125
  expect(r.merchants[0].lng).toBeCloseTo(geocoded.lng);
125
126
  expect(r.precise_location).toBe(false); // came from geocode, not GPS
126
127
  });
127
128
 
128
- it('falls back to offline when live fetch throws', async () => {
129
- const src = createBtcMapToolSource({
130
- location: locationOnly,
131
- fetch: async () => { throw new Error('btcmap is down'); },
132
- });
133
- const r = await exec(src, 'find_merchant_locations', {});
134
- expect(r.source).toBe('offline');
135
- expect(r.merchants.length).toBeGreaterThan(0);
136
- });
137
-
138
- it('falls back to offline when location resolves but fetch is missing', async () => {
139
- const src = createBtcMapToolSource({ location: locationOnly });
140
- const r = await exec(src, 'find_merchant_locations', {});
141
- expect(r.source).toBe('offline'); // no fetch injected → no live path
142
- });
143
- });
144
-
145
- describe('get_merchant_info', () => {
146
- it('finds a merchant by id', async () => {
147
- const src = createBtcMapToolSource();
148
- const r = await exec(src, 'get_merchant_info', { merchant_id: 'nyc-pubkey' });
149
- expect(r.success).toBe(true);
150
- expect(r.merchant.name).toBe('PubKey');
151
- expect(r.merchant.accepts_lightning).toBe(true);
152
- });
153
-
154
- it('finds a merchant by exact name', async () => {
155
- const src = createBtcMapToolSource();
156
- const r = await exec(src, 'get_merchant_info', { merchant_name: 'Bistro Libertine' });
157
- expect(r.success).toBe(true);
158
- expect(r.merchant.city).toBe('Lugano');
159
- });
160
-
161
- it('returns an error and possible suggestions when the name does not match', async () => {
162
- const src = createBtcMapToolSource();
163
- const r = await exec(src, 'get_merchant_info', { merchant_name: 'Nonexistent Merchant Name' });
164
- expect(r.success).toBe(false);
165
- expect(typeof r.error).toBe('string');
166
- });
129
+ it('clamps limit to 1–20 with a default of 10', async () => {
130
+ const fetchImpl: BtcMapFetch = async (q) =>
131
+ Array.from({ length: q.limit }, (_, i) => ({
132
+ id: i, name: `m${i}`, lat: 0, lng: 0, acceptedAssets: ['lightning'],
133
+ })) as BtcMapMerchant[];
134
+ const src = createBtcMapToolSource({ location: locationOnly, fetch: fetchImpl });
167
135
 
168
- it('treats a fuzzy-close name as a hit (not an error)', async () => {
169
- const src = createBtcMapToolSource();
170
- const r = await exec(src, 'get_merchant_info', { merchant_name: 'Pubkey' }); // PubKey
171
- expect(r.success).toBe(true);
172
- expect(r.merchant.name).toBe('PubKey');
173
- });
136
+ const dflt = await exec(src, 'find_merchant_locations', {});
137
+ expect(dflt.merchants.length).toBe(10);
174
138
 
175
- it('returns an error (no throw) when given nothing', async () => {
176
- const src = createBtcMapToolSource();
177
- const r = await exec(src, 'get_merchant_info', {});
178
- expect(r.success).toBe(false);
179
- });
180
- });
139
+ const big = await exec(src, 'find_merchant_locations', { limit: 9999 });
140
+ expect(big.merchants.length).toBe(20); // clamped to 20
181
141
 
182
- describe('BTC_MAP_SAMPLE', () => {
183
- it('has a row in Lugano with Lightning support (sanity)', () => {
184
- const lugano = BTC_MAP_SAMPLE.filter((m) => m.city === 'Lugano');
185
- expect(lugano.length).toBeGreaterThan(0);
186
- expect(lugano.every((m) => m.acceptedAssets?.includes('lightning'))).toBe(true);
142
+ const tiny = await exec(src, 'find_merchant_locations', { limit: -5 });
143
+ expect(tiny.merchants.length).toBe(1); // clamped to 1
187
144
  });
188
145
  });
@@ -1,15 +1,15 @@
1
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.
2
+ * BTC Map tool source — exposes `find_merchant_locations` so the agent can
3
+ * answer "where can I spend Bitcoin near me?" using the SAME tool name on
4
+ * every surface.
5
5
  *
6
6
  * - mobile → injects device GPS + a live BTC Map fetch
7
7
  * - desktop → injects a live fetch (or a server-side cache)
8
- * - eval / playground → no injection, falls back to the bundled offline list
9
8
  *
10
9
  * 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.
10
+ * `location` resolver and the `fetch` adapter. Without them the tool returns
11
+ * a clear error: there is NO offline / sample fallback (intentional fake
12
+ * data is worse than a clean failure that tells the user what's wrong).
13
13
  *
14
14
  * The result shape mirrors what rate's host returns today, so a mobile host
15
15
  * can swap its bespoke merchant tools for this factory verbatim.
@@ -20,7 +20,6 @@ import type { ToolSource } from '../tools/source.js';
20
20
  import type { Merchant } from './merchants.js';
21
21
 
22
22
  const FIND = 'find_merchant_locations';
23
- const INFO = 'get_merchant_info';
24
23
 
25
24
  /** A geographic point + optional human label. */
26
25
  export interface LatLng {
@@ -34,8 +33,9 @@ export interface LatLng {
34
33
 
35
34
  /**
36
35
  * 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.
36
+ * Node typically doesn't. When the host can't resolve a location (no GPS, no
37
+ * geocoder, or `near_address` won't geocode), the tool returns a clean
38
+ * `success:false` error instead of falling back to fake data.
39
39
  */
40
40
  export interface LocationProvider {
41
41
  /** Resolve "near me" — the device location, or null if unavailable. */
@@ -72,126 +72,10 @@ export interface BtcMapToolOptions {
72
72
  location?: LocationProvider;
73
73
  /** Hit a live BTC Map (or your own cache). Host-injected — no network in core. */
74
74
  fetch?: BtcMapFetch;
75
- /** Offline list used when no location/fetch is available. Defaults to BTC_MAP_SAMPLE. */
76
- offlineMerchants?: Merchant[];
77
75
  /** Default for `limit` when the caller doesn't set one. Default 10. */
78
76
  k?: number;
79
77
  }
80
78
 
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
79
  /** Clamp a value to a numeric range, returning the default when input is bad. */
196
80
  function clamp(n: unknown, lo: number, hi: number, dflt: number): number {
197
81
  const v = Number(n);
@@ -199,23 +83,6 @@ function clamp(n: unknown, lo: number, hi: number, dflt: number): number {
199
83
  return Math.min(hi, Math.max(lo, v));
200
84
  }
201
85
 
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
86
  /** Map a Merchant → the response row the model sees (stable for rate parity). */
220
87
  function row(m: BtcMapMerchant) {
221
88
  return {
@@ -234,49 +101,18 @@ function row(m: BtcMapMerchant) {
234
101
  };
235
102
  }
236
103
 
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
104
  /**
268
- * Build a ToolSource exposing `find_merchant_locations` + `get_merchant_info`.
105
+ * Build a ToolSource exposing `find_merchant_locations`.
269
106
  *
270
- * Resolution order for `find_merchant_locations`:
107
+ * Resolution order:
271
108
  * 1. `near_address` provided → opts.location.geocode → opts.fetch (live)
272
109
  * 2. opts.location.getCurrent → opts.fetch (live)
273
- * 3. fall through to offline substring search over opts.offlineMerchants
274
110
  *
275
- * Any step that fails silently falls through to the next, so the skill is
276
- * always answerable.
111
+ * Any step that fails returns `{success:false, error}` with a message that
112
+ * tells the user (and the model) what's actually wrong — no fake-data
113
+ * fallback. Either we have a real merchant list or we say we don't.
277
114
  */
278
115
  export function createBtcMapToolSource(opts: BtcMapToolOptions = {}): ToolSource {
279
- const offline = opts.offlineMerchants ?? BTC_MAP_SAMPLE;
280
116
  const defaultLimit = opts.k ?? 10;
281
117
 
282
118
  const find: ToolDef = {
@@ -285,56 +121,25 @@ export function createBtcMapToolSource(opts: BtcMapToolOptions = {}): ToolSource
285
121
  "Find Bitcoin-accepting merchants near the user using live BTC Map data " +
286
122
  "and the device's real location when available. Use when the user wants " +
287
123
  '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").',
124
+ 'Bitcoin nearby. Map the request to the smallest useful set of arguments. ' +
125
+ 'For generic "where can I spend sats / bitcoin merchants in X" use only ' +
126
+ 'near_address (or nothing). Never put "sats", "btc", "bitcoin" or spend verbs ' +
127
+ 'into query or category — this source is already Bitcoin-only. When uncertain ' +
128
+ 'or the request is generic, prefer fewer fields rather than guessing. ' +
129
+ 'The returned merchants (or a clean error) are the only factual source — ' +
130
+ 'never invent places.',
290
131
  parameters: {
291
132
  type: 'object',
292
133
  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)' },
134
+ query: { type: 'string', description: 'OPTIONAL a name, food type, or descriptive term implied by the user (e.g. "coffee", "pizza", "food", "atm", "shop"). Good for vague or natural phrasing. **OMIT** for generic "spend sats", "where can I spend", "merchants", or "places to spend btc". **NEVER** include "sats", "btc", "bitcoin", "spend", or currency terms — the source is already Bitcoin-only and this over-filters to zero results.' },
135
+ category: { type: 'string', description: 'OPTIONAL — set only when the request cleanly matches one specific allowed venue type: restaurant, cafe, bar, shop, grocery, lodging, atm. Leave empty for generic or mixed "spend sats / where to spend bitcoin" requests. Never guess a category (e.g. "shop") for a generic spend query. Never use generic nouns like merchant/place/store as the category value.' },
136
+ near_address: { type: 'string', description: 'City or address to search around when the user names a location instead of (or with) "near me". e.g. "Lugano", "Lisbon", "the center". The host will attempt to geocode it.' },
137
+ radius_km: { type: 'number', description: 'OPTIONAL — search radius in km (0.25–50). OMIT entirely unless the user explicitly named a distance ("within 2 km"). The default (5) is already applied server-side.' },
138
+ limit: { type: 'number', description: 'OPTIONAL max results (1–20, default 10). Omit unless the user named a count.' },
298
139
  },
299
140
  },
300
141
  };
301
142
 
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
143
  async function resolveCenter(near_address: string | undefined): Promise<LatLng | null> {
339
144
  if (!opts.location) return null;
340
145
  if (near_address && near_address.trim().length >= 2 && opts.location.geocode) {
@@ -342,7 +147,7 @@ export function createBtcMapToolSource(opts: BtcMapToolOptions = {}): ToolSource
342
147
  const pt = await opts.location.geocode(near_address);
343
148
  if (pt) return { ...pt, label: near_address, precise: false };
344
149
  } catch {
345
- /* fall through */
150
+ /* geocode failed — fall through to getCurrent */
346
151
  }
347
152
  }
348
153
  try {
@@ -360,87 +165,67 @@ export function createBtcMapToolSource(opts: BtcMapToolOptions = {}): ToolSource
360
165
  const limit = clamp(args.limit, 1, 20, defaultLimit);
361
166
  const radiusMeters = radius_km * 1000;
362
167
 
363
- // 1 + 2. Try the live path when we can.
168
+ // Live path requires BOTH a location resolver and a fetch adapter — they
169
+ // are host-injected. Without either, the tool can't produce real merchant
170
+ // data, and we refuse to invent.
171
+ if (!opts.fetch) {
172
+ return {
173
+ success: false,
174
+ error:
175
+ 'Merchant search is unavailable: the host has not injected a BTC Map fetch adapter. ' +
176
+ 'No offline / sample data is used — connect a fetcher or try again later.',
177
+ };
178
+ }
364
179
  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
- }
180
+ if (!center) {
181
+ return {
182
+ success: false,
183
+ error: near_address
184
+ ? `Could not locate "${near_address}". Check the spelling or try a nearby city.`
185
+ : 'Could not determine your location. Pass `near_address` (a city or address) to search a specific area.',
186
+ };
382
187
  }
383
188
 
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
- : [];
189
+ let merchants: BtcMapMerchant[];
190
+ try {
191
+ merchants = await opts.fetch({
192
+ center: { lat: center.lat, lng: center.lng },
193
+ radiusMeters,
194
+ query,
195
+ category,
196
+ limit,
197
+ });
198
+ } catch (e) {
425
199
  return {
426
200
  success: false,
427
- error: `Could not find merchant${nameArg ? ` "${nameArg}"` : idArg !== undefined ? ` with id ${idArg}` : ''}.`,
428
- suggestions: suggestions.length ? suggestions : undefined,
201
+ error: `BTC Map fetch failed: ${(e as Error)?.message ?? String(e)}.`,
429
202
  };
430
203
  }
431
- return { success: true, merchant: { ...row(m as BtcMapMerchant), city: m.city } };
204
+
205
+ const where = center.label || (center.precise ? 'your location' : 'the requested area');
206
+ return {
207
+ success: true,
208
+ source: 'btcmap',
209
+ precise_location: !!center.precise,
210
+ center: { lat: center.lat, lng: center.lng },
211
+ merchants: merchants.map(row),
212
+ total_found: merchants.length,
213
+ message:
214
+ merchants.length > 0
215
+ ? `Found ${merchants.length} Bitcoin merchant${merchants.length === 1 ? '' : 's'} near ${where}${query ? ` matching "${query}"` : ''}.`
216
+ : `No Bitcoin merchants found within ${radius_km} km of ${where}. Try widening the radius.`,
217
+ };
432
218
  }
433
219
 
434
220
  async function execute(name: string, args: Record<string, unknown>): Promise<unknown> {
435
221
  if (name === FIND) return findLocations(args);
436
- if (name === INFO) return getInfo(args);
437
222
  throw new Error(`btc-map: unknown tool "${name}"`);
438
223
  }
439
224
 
440
225
  return {
441
226
  id: 'btc-map',
442
- listTools: () => [find, info],
443
- has: (name) => name === FIND || name === INFO,
227
+ listTools: () => [find],
228
+ has: (name) => name === FIND,
444
229
  execute,
445
230
  };
446
231
  }