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>
270 lines
10 KiB
TypeScript
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 };
|
|
}
|