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>
This commit is contained in:
shakespeare.diy 2026-09-03 18:55:54 +07:00
parent 0b786e546f
commit 878da94b7b
6 changed files with 192 additions and 32 deletions

View file

@ -128,7 +128,7 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
{isAgent && ( {isAgent && (
<Avatar className="h-8 w-8 flex-shrink-0"> <Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" /> <AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-xs">🐝</AvatarFallback> <AvatarFallback className="bg-teal-900/40 text-teal-300 text-[10px] font-black">SQ</AvatarFallback>
</Avatar> </Avatar>
)} )}
<div className={cn('flex max-w-[82%] flex-col', !isAgent && 'items-end')}> <div className={cn('flex max-w-[82%] flex-col', !isAgent && 'items-end')}>
@ -149,9 +149,10 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
} }
const SUGGESTIONS = [ const SUGGESTIONS = [
"What's our next jam?",
'Who runs this crew?',
'What are we looking for?',
'Best spots near us?', 'Best spots near us?',
'Any events this month?',
'Find a filmer nearby',
"What's the weather for skating?", "What's the weather for skating?",
]; ];
@ -176,13 +177,17 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-0 overflow-hidden bg-black border-teal-500/30 flex flex-col max-h-[85vh]"> <DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-0 overflow-hidden bg-black border-teal-500/30 flex flex-col max-h-[85vh]">
<DialogHeader className="p-4 pb-3 border-b border-white/5"> <DialogHeader className="p-4 pb-3 border-b border-white/5">
<DialogTitle className="flex items-center gap-2 text-white"> <DialogTitle className="flex items-center gap-2 text-white">
<span className="text-lg leading-none">🐝</span> Ask Sasquatch <Avatar className="h-6 w-6">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[9px] font-black">SQ</AvatarFallback>
</Avatar>
Ask Sasquatch
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300"> <span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
Crew Agent Crew Agent
</span> </span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
Your crew's AI guide — spots, events, coaches & gear, with live data. Knows this crew — its jams, roster & open call — plus the wider scene with live data.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -191,10 +196,11 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<Avatar className="h-12 w-12"> <Avatar className="h-12 w-12">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" /> <AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300">🐝</AvatarFallback> <AvatarFallback className="bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
</Avatar> </Avatar>
<p className="text-sm text-muted-foreground max-w-[16rem]"> <p className="text-sm text-muted-foreground max-w-[16rem]">
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me anything about your local scene. Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me about the crew — our next jam,
who runs it, what we're after — or the wider scene.
</p> </p>
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
{SUGGESTIONS.map((s) => ( {SUGGESTIONS.map((s) => (

View file

@ -14,6 +14,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { SpotPicker } from '@/components/SpotPicker'; import { SpotPicker } from '@/components/SpotPicker';
import { LabLinkField } from '@/components/LabLinkField'; import { LabLinkField } from '@/components/LabLinkField';
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed'; import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
@ -779,13 +780,20 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<Label className="flex items-center gap-2 text-teal-400"> <Label className="flex items-center gap-2 text-teal-400">
<span className="text-base leading-none">🐝</span> Crew Agent <img
src={CREW_AGENT_AVATAR}
alt="Sasquatch"
className="h-5 w-5 rounded-md object-cover"
loading="lazy"
/>
Crew Agent
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span> <span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span>
</Label> </Label>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide for your crew. Members can ask it Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide who actually knows your crew.
about nearby spots, events, coaches and gear — it answers with live SkateSpots data and real links. Members can ask about <strong className="text-gray-300">your next jam, who runs the crew, what you're
Runs right in the browser; no keys or extra setup. It's early (free-tier AI, so occasionally slow). looking for and your vibe</strong> — plus nearby spots, events, coaches and gear from live SkateSpots
data. Runs right in the browser; no keys or setup. It's early (free-tier AI, so occasionally slow).
</p> </p>
</div> </div>
<button <button

View file

@ -11,20 +11,133 @@ export interface CrewAgentMessage {
receipts?: { tool: string; data: unknown }; 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 { export interface CrewContext {
crewName: string; crewName: string;
location?: string; location?: string;
/** City derived from location (best-effort) — helps seed CVM queries. */ /** City derived from location (best-effort) — helps seed CVM queries. */
city?: string; 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 { function buildCrewSystemPrompt(ctx: CrewContext): string {
const where = ctx.city || ctx.location || 'their local area'; const where = ctx.city || ctx.location || 'their local area';
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 page. Chill, skater-friendly, concise. const dossier = buildCrewDossier(ctx);
The crew's home turf is: ${ctx.location || 'unknown'}. When a member asks about "near us", "here", "local", or doesn't give a city, assume ${where}. 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.
## Tools — query live data. Respond with ONLY a JSON tool call when you need data, nothing else on that line: ## 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":{}} {"tool":"<name>","args":{}}
- find_spots — args: city?, country?, difficulty?, features?(string[]), search?, limit? - find_spots — args: city?, country?, difficulty?, features?(string[]), search?, limit?
@ -36,17 +149,20 @@ The crew's home turf is: ${ctx.location || 'unknown'}. When a member asks about
- get_weather — args: city (required) - get_weather — args: city (required)
Rules: Rules:
1. Data questions (spots/events/coaches/gear/stats/weather) → ALWAYS use a tool, never guess. 1. Questions about THIS crew (its events, roster, vibe, open call, founder, band, home spots, website) → answer from memory, NO tool.
2. Tool call = ONE JSON object on its own line. For local queries seed the args with the crew's city (${where}) and limit:20. 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. Be concise and skater-friendly. Never invent spot names, dates, or user data. 3. A tool call is ONE JSON object on its own line — nothing else.
4. 0 results = honest "none listed yet" + encourage adding content at skate-spots.org.`; 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 (Phase 2). Reuses the CVM data tools and the * Crew-scoped Sasquatch chat. Reuses the CVM data tools and the shared AI
* shared AI brain, seeded with the crew's context. Ephemeral/local — no keys, * brain, but is seeded with a rich, crew-specific dossier (its own events,
* no publishing (that's Phase 3). Answers can carry structured `receipts` so * feed digest, roster, open call, vibe, band, home spots) so it can answer
* the UI renders spot / Lab / event cards. * 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) { export function useCrewAgentChat(ctx: CrewContext) {
const cvm = useCVMClient(); const cvm = useCVMClient();
@ -91,7 +207,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
try { try {
const system = buildCrewSystemPrompt(ctx); const system = buildCrewSystemPrompt(ctx);
// First pass — AI answers directly or emits a tool call. // First pass — AI answers directly from crew memory or emits a tool call.
const firstPass: ChatTurn[] = [ const firstPass: ChatTurn[] = [
{ role: 'user', content: `${system}\n\n---\n\nMember: ${q}` }, { role: 'user', content: `${system}\n\n---\n\nMember: ${q}` },
]; ];
@ -119,7 +235,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
const presented = await callSasquatchAI([ const presented = await callSasquatchAI([
{ {
role: 'user', role: 'user',
content: `You are 🐝 Sasquatch. Present this live data to the crew member naturally and concisely. Do NOT output any JSON. Plain conversational text with short bullet points. 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}" Member asked: "${q}"

View file

@ -1,7 +1,7 @@
/** /**
* Crew Agent identity (Phase 0) * Crew Agent identity (Phase 0)
* ───────────────────────────── * ─────────────────────────────
* The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew. * The Crew Agent ("Sasquatch") is a Nostr identity that belongs to a crew.
* Its key is DETERMINISTICALLY DERIVED from the crew's addressable coordinate, * Its key is DETERMINISTICALLY DERIVED from the crew's addressable coordinate,
* so any client can reproduce the same agent identity for a given crew without * so any client can reproduce the same agent identity for a given crew without
* storing a key anywhere. * storing a key anywhere.
@ -75,7 +75,7 @@ export const CREW_AGENT_AVATAR =
*/ */
export function buildCrewAgentProfile(crewName: string): EventTemplate { export function buildCrewAgentProfile(crewName: string): EventTemplate {
const metadata = { const metadata = {
name: `🐝 Sasquatch · ${crewName}`, name: `Sasquatch · ${crewName}`,
display_name: `Sasquatch · ${crewName}`, display_name: `Sasquatch · ${crewName}`,
about: `The AI crew guide for ${crewName} on SkateSpots. Ask me about spots, events, coaches, gear and more — I answer with live data. 🛹`, about: `The AI crew guide for ${crewName} on SkateSpots. Ask me about spots, events, coaches, gear and more — I answer with live data. 🛹`,
picture: CREW_AGENT_AVATAR, picture: CREW_AGENT_AVATAR,

View file

@ -1923,7 +1923,7 @@ export interface SkateCrew {
image?: string; // Crew logo / photo image?: string; // Crew logo / photo
vibes: string[]; // Style/focus tags vibes: string[]; // Style/focus tags
focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed' focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed'
agentEnabled: boolean; // Whether the crew's AI agent ("🐝 Sasquatch") is on agentEnabled: boolean; // Whether the crew's AI agent ("Sasquatch") is on
founded?: string; // Year founded (cosmetic) founded?: string; // Year founded (cosmetic)
website?: string; website?: string;
instagram?: string; instagram?: string;

View file

@ -22,6 +22,7 @@ import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread'; import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
import { CrewChat } from '@/components/CrewChat'; import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard'; import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
import { CrewLookingForCard } from '@/components/CrewLookingFor'; import { CrewLookingForCard } from '@/components/CrewLookingFor';
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin'; import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
@ -54,7 +55,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY, type CrewRole } from '@/lib/types'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import NotFound from './NotFound'; import NotFound from './NotFound';
@ -336,8 +337,11 @@ export default function CrewDetailPage() {
onClick={() => setAgentOpen(true)} onClick={() => setAgentOpen(true)}
className="group flex w-full items-center gap-3 rounded-2xl border-2 border-teal-500/30 bg-gradient-to-br from-teal-500/10 to-transparent p-4 text-left transition-all hover:border-teal-500/60 hover:from-teal-500/15 active:scale-[0.99]" className="group flex w-full items-center gap-3 rounded-2xl border-2 border-teal-500/30 bg-gradient-to-br from-teal-500/10 to-transparent p-4 text-left transition-all hover:border-teal-500/60 hover:from-teal-500/15 active:scale-[0.99]"
> >
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-teal-500/15 text-2xl"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-teal-500/15">
🐝 <Avatar className="h-12 w-12 rounded-2xl">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
</Avatar>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-black text-white"> <p className="flex items-center gap-2 font-black text-white">
@ -619,12 +623,38 @@ export default function CrewDetailPage() {
</div> </div>
</div> </div>
{/* Crew Agent chat panel */} {/* Crew Agent chat panel — seeded with the crew's OWN data so Sasquatch
can answer crew-specific questions (its events, roster, open call,
vibe, band, home spots) directly, not just re-skin the global bot. */}
{crew.agentEnabled && ( {crew.agentEnabled && (
<CrewAgentPanel <CrewAgentPanel
open={agentOpen} open={agentOpen}
onOpenChange={setAgentOpen} onOpenChange={setAgentOpen}
ctx={{ crewName: crew.name, location: crew.location, city: crew.location }} ctx={{
crewName: crew.name,
location: crew.location,
city: crew.location,
bio: crew.bio,
vibes: crew.vibes,
founderName,
memberCount: 1 + crew.members.length,
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_LABELS[s]?.label ?? s),
bandName: crew.bandName,
homeSpotCount: crew.spotRefs.length,
website: crew.website,
instagram: crew.instagram,
events: (crewEvents ?? []).map((e) => ({
title: e.title,
start: e.start,
location: e.location,
path: `/event/${e.pubkey}/${e.identifier}`,
})),
recentPosts: (feed ?? []).slice(0, 5).map((ev) => ({
author: genUserName(ev.pubkey),
text: ev.content ?? '',
createdAt: ev.created_at,
})),
}}
/> />
)} )}
</Layout> </Layout>