CVM find_crews: Sasquatch can now live-search crews

- useCVMClient: add findCrews (queries kind 35006 across data relays, filters by
  city/country/vibe/search, returns CrewResult with member_count, vibes, url);
  add crews to getCommunityStats + CrewResult type
- SasquatchGuide: register find_crews tool in system prompt + switch handler,
  add crew keywords to needsLiveData, update app-facts (crews are searchable +
  mention cross-linking and Zap-the-Crew)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 14:35:16 +07:00
parent f881c6513f
commit dec2e730d2
2 changed files with 110 additions and 3 deletions

View file

@ -170,6 +170,12 @@ Example: {"tool":"find_coaches","args":{"location":"Bangkok","limit":20}}
Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number) Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number)
Example: {"tool":"find_gear","args":{"category":"deck"}} Example: {"tool":"find_gear","args":{"category":"deck"}}
### find_crews
Find skate crews (community groups). Args: city?(string), country?(string), vibe?(string e.g. "street"|"transition"|"diy"), search?(string), limit?(number)
- For country queries use the main city too (Thailand → city:"Bangkok")
- Always use limit:20+ for city/country searches
Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}}
### get_community_stats ### get_community_stats
Args: none Args: none
Example: {"tool":"get_community_stats","args":{}} Example: {"tool":"get_community_stats","args":{}}
@ -183,7 +189,7 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}}
## Rules ## Rules
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess 1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess
2. Tool call = ONE JSON object on its own line, nothing else before or after 2. Tool call = ONE JSON object on its own line, nothing else before or after
3. App/protocol/how-to questions → answer directly (no tool). Crews, Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. 3. App/protocol/how-to questions → answer directly (no tool). Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. (Crews DO have find_crews — use it for "find/show crews".)
4. Be concise, skater-friendly 4. Be concise, skater-friendly
5. Never invent spot names, event dates, or user data 5. Never invent spot names, event dates, or user data
6. 0 results = honest "none listed yet" + encourage adding content 6. 0 results = honest "none listed yet" + encourage adding content
@ -192,7 +198,7 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}}
- Nostr = decentralized protocol; your cryptographic keys ARE your identity — one login works everywhere, nobody can ban you - Nostr = decentralized protocol; your cryptographic keys ARE your identity — one login works everywhere, nobody can ban you
- Map (the homepage) = discover spots, shops, gear, tours, events and crews worldwide. Toggle layers via the Layers control. Add a spot with the + button. - Map (the homepage) = discover spots, shops, gear, tours, events and crews worldwide. Toggle layers via the Layers control. Add a spot with the + button.
- The Lab = hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Providers can be an individual OR a collective (crew/studio) — collective members confirm with their own key (green = confirmed, amber = pending). - The Lab = hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Providers can be an individual OR a collective (crew/studio) — collective members confirm with their own key (green = confirmed, amber = pending).
- Crews = your map-anchored skate family (who you skate with, NOT for hire). A crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Create one at /crews → "Start a Crew". Crews reuse the same member-confirmation trust as Lab collectives. - Crews = your map-anchored skate family (who you skate with, NOT for hire). A crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Create one at /crews → "Start a Crew". Crews reuse the same member-confirmation trust as Lab collectives. Use find_crews to search them. A crew can cross-link its Lab service listings, and you can "Zap the Crew" to split a tip across confirmed members.
- Events = find/join skate events & comps (map layer + Events page) - Events = find/join skate events & comps (map layer + Events page)
- Tours = guided local/national/world skate tours - Tours = guided local/national/world skate tours
- Skate Shops = local shops on the map - Skate Shops = local shops on the map
@ -219,6 +225,7 @@ function needsLiveData(question: string): boolean {
'events in', 'events near', 'upcoming', 'coaches in', 'filmers in', 'events in', 'events near', 'upcoming', 'coaches in', 'filmers in',
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count', 'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
'list', 'nearby', 'close to', 'around', 'available', 'list', 'nearby', 'close to', 'around', 'available',
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around',
]; ];
return liveKeywords.some((kw) => q.includes(kw)); return liveKeywords.some((kw) => q.includes(kw));
} }
@ -497,6 +504,9 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
case 'find_gear': case 'find_gear':
toolResult = await cvm.findGear(toolArgs as Parameters<typeof cvm.findGear>[0]); toolResult = await cvm.findGear(toolArgs as Parameters<typeof cvm.findGear>[0]);
break; break;
case 'find_crews':
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
break;
case 'get_community_stats': case 'get_community_stats':
toolResult = await cvm.getCommunityStats(); toolResult = await cvm.getCommunityStats();
break; break;

View file

@ -74,6 +74,21 @@ export interface GearResult {
created_at: string; created_at: string;
} }
export interface CrewResult {
name: string;
description: string;
location?: string;
vibes: string[];
member_count: number;
founded?: string;
geohash?: string;
image?: string;
founder_pubkey: string;
naddr: string;
url: string;
created_at: string;
}
export interface CommunityStats { export interface CommunityStats {
spots: number; spots: number;
upcoming_events: number; upcoming_events: number;
@ -81,6 +96,7 @@ export interface CommunityStats {
filmers: number; filmers: number;
editors: number; editors: number;
gear_listings: number; gear_listings: number;
crews: number;
relays_queried: string[]; relays_queried: string[];
queried_at: string; queried_at: string;
} }
@ -440,18 +456,95 @@ export function useCVMClient() {
[nostr] [nostr]
); );
const findCrews = useCallback(
async (params: {
city?: string;
country?: string;
vibe?: string;
search?: string;
limit?: number;
}): Promise<{ count: number; crews: CrewResult[] }> => {
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query(
[{ kinds: [35006], limit: 500 }],
{ signal }
);
let crews = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
// Only valid crews
crews = crews.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew'));
const locMatch = (e: typeof crews[0], q: string) =>
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q);
if (params.city) {
const q = params.city.toLowerCase();
crews = crews.filter((e) => locMatch(e, q));
}
if (params.country) {
const q = params.country.toLowerCase();
crews = crews.filter((e) => locMatch(e, q));
}
if (params.vibe) {
const q = params.vibe.toLowerCase();
crews = crews.filter((e) =>
getTags(e, 't').some((t) => t !== 'skatecrew' && t.toLowerCase().includes(q))
);
}
if (params.search) {
const q = params.search.toLowerCase();
crews = crews.filter(
(e) =>
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q) ||
getTags(e, 't').some((t) => t.toLowerCase().includes(q))
);
}
crews.sort((a, b) => b.created_at - a.created_at);
crews = crews.slice(0, params.limit ?? 10);
const result: CrewResult[] = crews.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
return {
name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Crew',
description: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
vibes: getTags(ev, 't').filter((t) => t !== 'skatecrew'),
member_count: ev.tags.filter((t) => t[0] === 'p').length,
founded: getTag(ev, 'founded'),
geohash: getTag(ev, 'g'),
image: getTag(ev, 'image'),
founder_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
url: `https://skate-spots.org/crew/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
created_at: new Date(ev.created_at * 1000).toISOString(),
};
});
return { count: result.length, crews: result };
},
[nostr]
);
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => { const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
const signal = AbortSignal.any([AbortSignal.timeout(10000)]); const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS); const relayGroup = nostr.group(DATA_RELAYS);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const [spots, events, coaches, filmers, editors, gear] = await Promise.all([ const [spots, events, coaches, filmers, editors, gear, crews] = await Promise.all([
relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }), relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }),
relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }), relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }),
relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }), relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }),
]); ]);
return { return {
@ -463,6 +556,9 @@ export function useCVMClient() {
filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length, filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length,
editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length, editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length,
gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length, gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length,
crews: deduplicateAddressable(crews as typeof crews & { tags: string[][] }[]).filter(
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')
).length,
relays_queried: DATA_RELAYS, relays_queried: DATA_RELAYS,
queried_at: new Date().toISOString(), queried_at: new Date().toISOString(),
}; };
@ -473,6 +569,7 @@ export function useCVMClient() {
findEvents, findEvents,
findCoaches, findCoaches,
findGear, findGear,
findCrews,
getCommunityStats, getCommunityStats,
}; };
} }