SkateSpots/src/hooks/useCrewAgentChat.ts
shakespeare.diy 878da94b7b Make Crew Agent genuinely crew-aware; swap bee emoji for Sasquatch avatar
Sasquatch now knows the crew it lives on. The agent is seeded with a rich
crew dossier — its own upcoming events, recent feed digest, roster size,
founder, open call, vibe, band, home spots, website/IG — and the system
prompt instructs it to answer crew-specific questions ("what's our next
jam?", "who runs this?", "what are we looking for?") directly from memory,
while still using CVM tools for wider-network discovery.

- useCrewAgentChat: expanded CrewContext + crew dossier + two-mode prompt
- CrewDetailPage: wires real crew events + feed digest into the agent ctx
- CrewAgentPanel: crew-specific suggestions, crew-aware copy
- Replaced 🐝 bee emoji with the Sasquatch mascot avatar everywhere
  (panel header/rows/empty state, crew page button, CrewForm toggle)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 18:55:54 +07:00

270 lines
10 KiB
TypeScript

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/<pubkey>/<d>. */
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":"<name>","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<CrewAgentMessage[]>([]);
const [isThinking, setIsThinking] = useState(false);
const append = useCallback((m: Omit<CrewAgentMessage, 'id' | 'timestamp'>) => {
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<string, unknown>): Promise<unknown> => {
switch (tool) {
case 'find_spots':
return cvm.findSpots(args as Parameters<typeof cvm.findSpots>[0]);
case 'find_events':
return cvm.findEvents(args as Parameters<typeof cvm.findEvents>[0]);
case 'find_coaches':
return cvm.findCoaches(args as Parameters<typeof cvm.findCoaches>[0]);
case 'find_gear':
return cvm.findGear(args as Parameters<typeof cvm.findGear>[0]);
case 'find_crews':
return cvm.findCrews(args as Parameters<typeof cvm.findCrews>[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 };
}