@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.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/qvac/parse.d.ts +15 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +68 -5
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/text.d.ts.map +1 -1
- package/dist/qvac/text.js +4 -0
- package/dist/qvac/text.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +1 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/qvac/parse.test.ts +70 -1
- package/src/qvac/parse.ts +71 -5
- package/src/qvac/text.ts +4 -0
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- 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
|
|
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'
|
|
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(
|
|
24
|
-
expect(src.has('find_merchants')).toBe(false);
|
|
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
|
|
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(
|
|
38
|
-
expect(r.
|
|
39
|
-
expect(r.
|
|
40
|
-
expect(r.merchants
|
|
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('
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
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('
|
|
59
|
-
const src = createBtcMapToolSource(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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('
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
expect(r.
|
|
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
|
|
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('
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
});
|
package/src/knowledge/btc-map.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BTC Map tool source — exposes `find_merchant_locations`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
|
38
|
-
*
|
|
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
|
|
105
|
+
* Build a ToolSource exposing `find_merchant_locations`.
|
|
269
106
|
*
|
|
270
|
-
* Resolution order
|
|
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
|
|
276
|
-
*
|
|
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.
|
|
289
|
-
'
|
|
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: '
|
|
294
|
-
category: { type: 'string', description: 'restaurant
|
|
295
|
-
near_address: { type: 'string', description: '
|
|
296
|
-
radius_km: { type: 'number', description: '
|
|
297
|
-
limit: { type: 'number', description: '
|
|
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
|
-
//
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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: `
|
|
428
|
-
suggestions: suggestions.length ? suggestions : undefined,
|
|
201
|
+
error: `BTC Map fetch failed: ${(e as Error)?.message ?? String(e)}.`,
|
|
429
202
|
};
|
|
430
203
|
}
|
|
431
|
-
|
|
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
|
|
443
|
-
has: (name) => name === FIND
|
|
227
|
+
listTools: () => [find],
|
|
228
|
+
has: (name) => name === FIND,
|
|
444
229
|
execute,
|
|
445
230
|
};
|
|
446
231
|
}
|