import { useState, useCallback } from 'react'; import { useCVMClient } from '@/hooks/useCVMClient'; import { callSasquatchAI, parseToolCall, type ChatTurn } from '@/lib/sasquatchBrain'; export interface CrewAgentMessage { id: string; role: 'user' | 'agent'; text: string; timestamp: number; /** Raw structured tool result, so the UI can render receipt cards. */ receipts?: { tool: string; data: unknown }; } /** A crew-hosted event, flattened for the agent's context. */ export interface CrewAgentEvent { title: string; /** Unix seconds. */ start: number; location?: string; /** In-app path to the event, e.g. /event//. */ path?: string; } /** A recent crew-feed post, flattened for the agent's context. */ export interface CrewAgentPost { author: string; /** Short excerpt of the note. */ text: string; /** Unix seconds. */ createdAt: number; } export interface CrewContext { crewName: string; location?: string; /** City derived from location (best-effort) — helps seed CVM queries. */ city?: string; /** Freeform crew manifesto / about. */ bio?: string; /** Style / focus tags. */ vibes?: string[]; /** Founder display name. */ founderName?: string; /** Total member count (founder + listed members). */ memberCount?: number; /** Human-readable open-call labels, e.g. ["Filmer", "New Members"]. */ lookingFor?: string[]; /** Crew band name, if the crew doubles as a music project. */ bandName?: string; /** Number of home spots linked to the crew. */ homeSpotCount?: number; /** Crew website. */ website?: string; /** Crew Instagram handle. */ instagram?: string; /** The crew's OWN upcoming/recent events — soonest first. */ events?: CrewAgentEvent[]; /** A short digest of the crew's recent feed activity. */ recentPosts?: CrewAgentPost[]; } function fmtDate(unix: number): string { try { return new Date(unix * 1000).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', }); } catch { return 'TBD'; } } /** * Build a compact, human-readable digest of everything Sasquatch knows about * THIS crew. This is the crew's "memory" — the stuff no global bot could know — * and is injected into every turn so the agent answers crew questions directly. */ function buildCrewDossier(ctx: CrewContext): string { const lines: string[] = []; const where = ctx.location || 'unknown'; lines.push(`Crew name: ${ctx.crewName}`); lines.push(`Home turf: ${where}`); if (ctx.founderName) lines.push(`Founded/run by: ${ctx.founderName}`); if (typeof ctx.memberCount === 'number') { lines.push(`Roster size: ${ctx.memberCount} ${ctx.memberCount === 1 ? 'member' : 'members'}`); } if (ctx.vibes?.length) lines.push(`Vibe / style: ${ctx.vibes.join(', ')}`); if (ctx.bio) lines.push(`About: ${ctx.bio.slice(0, 400)}`); if (ctx.bandName) lines.push(`Crew band: ${ctx.bandName}`); if (typeof ctx.homeSpotCount === 'number' && ctx.homeSpotCount > 0) { lines.push(`Home spots on file: ${ctx.homeSpotCount}`); } if (ctx.lookingFor?.length) { lines.push(`Open call — the crew is looking for: ${ctx.lookingFor.join(', ')}`); } if (ctx.website) lines.push(`Website: ${ctx.website}`); if (ctx.instagram) lines.push(`Instagram: @${ctx.instagram.replace('@', '')}`); // The crew's OWN events — the highest-value crew-specific data. if (ctx.events?.length) { const now = Math.floor(Date.now() / 1000); const upcoming = ctx.events.filter((e) => e.start >= now); const list = (upcoming.length ? upcoming : ctx.events).slice(0, 6); lines.push( `\nCrew's own events (${upcoming.length ? 'upcoming' : 'most recent'}):`, ); for (const e of list) { const loc = e.location ? ` @ ${e.location}` : ''; lines.push(`- "${e.title}" — ${fmtDate(e.start)}${loc}`); } } else { lines.push('\nCrew events: none hosted yet.'); } // Recent feed digest — gives the agent a sense of what the crew is up to. if (ctx.recentPosts?.length) { lines.push('\nRecent crew feed activity:'); for (const p of ctx.recentPosts.slice(0, 5)) { lines.push(`- ${p.author}: "${p.text.slice(0, 120).replace(/\s+/g, ' ')}"`); } } return lines.join('\n'); } function buildCrewSystemPrompt(ctx: CrewContext): string { const where = ctx.city || ctx.location || 'their local area'; const dossier = buildCrewDossier(ctx); return `You are Sasquatch, the AI guide for the "${ctx.crewName}" skate crew on SkateSpots (a decentralized skateboarding platform on Nostr). You're chatting inside the crew's own page with its members. Chill, skater-friendly, concise, and you know THIS crew personally. ## What you know about this crew (your memory) ${dossier} When a member asks about "us", "our crew", "here", "our next jam", "who runs this", "what are we looking for", "our vibe", etc. — answer DIRECTLY from the crew memory above. Do NOT call a tool for questions about the crew itself. If the memory doesn't cover it, say so honestly. ## Tools — for discovering things OUTSIDE the crew (the wider SkateSpots network). Respond with ONLY a JSON tool call when you need external data, nothing else on that line: {"tool":"","args":{}} - find_spots — args: city?, country?, difficulty?, features?(string[]), search?, limit? - find_events — args: city?, country?, event_type?, from_date?, to_date?, limit? - find_coaches — args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?, limit? - find_gear — args: category?, condition?, max_price_sats?, search?, limit? - find_crews — args: city?, country?, vibe?, search?, limit? - get_community_stats — args: none - get_weather — args: city (required) Rules: 1. Questions about THIS crew (its events, roster, vibe, open call, founder, band, home spots, website) → answer from memory, NO tool. 2. Questions about the wider scene (spots/events/coaches/gear/crews near us, stats, weather) → ALWAYS use a tool, never guess. Seed local queries with the crew's city (${where}) and limit:20. 3. A tool call is ONE JSON object on its own line — nothing else. 4. Be concise and skater-friendly. Never invent spot names, dates, member names, or user data. 5. 0 results (or empty crew data) = honest "none listed yet" + encourage adding content at skate-spots.org.`; } /** * Crew-scoped Sasquatch chat. Reuses the CVM data tools and the shared AI * brain, but is seeded with a rich, crew-specific dossier (its own events, * feed digest, roster, open call, vibe, band, home spots) so it can answer * questions about the crew itself directly — not just re-skin the global bot. * Ephemeral/local — no keys, no publishing. Answers can carry structured * `receipts` so the UI renders spot / Lab / event cards. */ export function useCrewAgentChat(ctx: CrewContext) { const cvm = useCVMClient(); const [messages, setMessages] = useState([]); const [isThinking, setIsThinking] = useState(false); const append = useCallback((m: Omit) => { setMessages((prev) => [ ...prev, { ...m, id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, timestamp: Date.now() }, ]); }, []); const runTool = useCallback( async (tool: string, args: Record): Promise => { switch (tool) { case 'find_spots': return cvm.findSpots(args as Parameters[0]); case 'find_events': return cvm.findEvents(args as Parameters[0]); case 'find_coaches': return cvm.findCoaches(args as Parameters[0]); case 'find_gear': return cvm.findGear(args as Parameters[0]); case 'find_crews': return cvm.findCrews(args as Parameters[0]); case 'get_community_stats': return cvm.getCommunityStats(); default: return { error: `Unknown tool: ${tool}` }; } }, [cvm], ); const send = useCallback( async (question: string) => { const q = question.trim(); if (!q || isThinking) return; setIsThinking(true); append({ role: 'user', text: q }); try { const system = buildCrewSystemPrompt(ctx); // First pass — AI answers directly from crew memory or emits a tool call. const firstPass: ChatTurn[] = [ { role: 'user', content: `${system}\n\n---\n\nMember: ${q}` }, ]; const aiText = await callSasquatchAI(firstPass); const toolCall = parseToolCall(aiText); if (!toolCall) { append({ role: 'agent', text: aiText || "Hmm, couldn't process that. Try rephrasing!" }); return; } // Run the tool, then a second pass to present the data naturally. let toolResult: unknown; try { toolResult = await runTool(toolCall.tool, toolCall.args); } catch (toolErr) { console.warn('[CrewAgent] tool failed:', toolErr); append({ role: 'agent', text: "Couldn't reach the live data right now — try again in a moment. 🛹", }); return; } const presented = await callSasquatchAI([ { role: 'user', content: `You are Sasquatch, guide for the "${ctx.crewName}" crew. Present this live data to the crew member naturally and concisely. Do NOT output any JSON. Plain conversational text with short bullet points. Member asked: "${q}" Live data from ${toolCall.tool}: ${JSON.stringify(toolResult, null, 2)} - If count is 0: say nothing found yet, encourage adding at skate-spots.org - If count > 0: a short intro line — the app shows the results as cards below your message - Keep it short and skater-friendly. Never invent data.`, }, ]); append({ role: 'agent', text: presented || 'Here you go 👇', receipts: { tool: toolCall.tool, data: toolResult }, }); } catch (err) { console.warn('[CrewAgent] AI unavailable:', err); append({ role: 'agent', text: "The AI's a bit slow right now (free tier limits). Try again in a moment, or check the 🗺️ Map. 🛹", }); } finally { setIsThinking(false); } }, [ctx, isThinking, append, runTool], ); return { messages, isThinking, send }; }