Crew Agent Phases 0–2: opt-in AI guide (🐝 Sasquatch) per crew

Phase 0 — identity (lib/crewAgent.ts):
- Deterministic per-crew agent keypair derived from the crew coordinate
  (sha256 of a public salt + coord). Documented security tradeoff: the key
  is reproducible by design and holds no funds — used only for the agent to
  speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and
  a signAsCrewAgent helper for Phase 3.

Phase 1 — opt-in toggle:
- SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker.
- "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on.
- NIP.md documents the crew-agent tag and the agent identity derivation.

Phase 2 — crew-scoped Sasquatch with receipts:
- lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser.
- useCrewAgentChat: reuses the CVM data tools, seeded with crew context
  (name/location) so "near us" resolves to the crew's turf. Ephemeral/local,
  no keys, no publishing yet.
- CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt
  cards (spots / events / Lab pros / gear / crews) linking into the app.
- CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew
  enabled the agent.

Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 19:26:19 +07:00
parent 87c710bf4a
commit 2a55d1b3df
9 changed files with 685 additions and 3 deletions

26
NIP.md
View file

@ -1746,6 +1746,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
| `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — covers filming, photography & editing), or `crew-focus-coaching`. Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. |
| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
@ -1859,6 +1860,31 @@ spots on the map and cross-link back to the crew.
}
```
### Crew Agent identity (🐝 Sasquatch)
When a crew carries the `["t","crew-agent"]` marker, it opts into an **AI Crew
Agent** — a Nostr identity that belongs to the crew and can answer members'
questions with live SkateSpots data (spots, events, Lab pros, gear, weather).
The agent's keypair is **deterministically derived** from the crew coordinate:
```
secret = sha256("skatespots:crew-agent:v1" | "35006:<founderPubkey>:<dTag>")
pubkey = secp256k1(secret)
```
Any client can reproduce the same agent identity for a given crew with no stored
state. The agent publishes a kind-0 profile with `bot: true` (NIP-24) named
`🐝 Sasquatch · <CrewName>`.
> **Security note:** because the salt is public and the seed is public crew data,
> this key is *reproducible by anyone* and is **not a secure identity**. It is used
> only for the agent to speak for itself (its profile, and — in a later phase —
> helper replies in its own crew chat). It holds no funds and must never be trusted
> for sensitive signing. A future revision may replace this with a founder-controlled
> stored key without changing the querying model (the `crew-agent` marker stays the
> same).
## CLINK Payments (`noffer` tag)
SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.0.67",
"version": "1.0.68",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -0,0 +1,256 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Send, Loader2, MapPin, ChevronRight, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { useCrewAgentChat, type CrewContext, type CrewAgentMessage } from '@/hooks/useCrewAgentChat';
interface CrewAgentPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ctx: CrewContext;
}
/** Turn a skate-spots.org URL into an in-app path so links stay inside the app. */
function toAppPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
interface ReceiptItem {
key: string;
title: string;
subtitle?: string;
image?: string;
to: string;
}
/** Normalise a CVM tool result into flat receipt cards. */
function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
const d = data as Record<string, unknown>;
const take = <T,>(arr: unknown): T[] => (Array.isArray(arr) ? (arr as T[]).slice(0, 6) : []);
if (tool === 'find_spots') {
return take<Record<string, string>>(d.spots).map((s, i) => ({
key: `${s.url}-${i}`,
title: s.name || 'Spot',
subtitle: [s.difficulty, s.location].filter(Boolean).join(' · ') || undefined,
image: s.image,
to: toAppPath(s.url),
}));
}
if (tool === 'find_events') {
return take<Record<string, string>>(d.events).map((e, i) => ({
key: `${e.url}-${i}`,
title: e.title || 'Event',
subtitle: e.location,
image: e.image,
to: toAppPath(e.url),
}));
}
if (tool === 'find_coaches') {
return take<Record<string, string>>(d.profiles).map((p, i) => ({
key: `${p.url}-${i}`,
title: p.title || p.role || 'Lab pro',
subtitle: [p.role, p.location].filter(Boolean).join(' · ') || undefined,
image: p.image,
to: toAppPath(p.url),
}));
}
if (tool === 'find_gear') {
return take<Record<string, string>>(d.listings).map((g, i) => ({
key: `${g.url}-${i}`,
title: g.title || 'Gear',
subtitle: g.condition,
image: g.image,
to: toAppPath(g.url),
}));
}
if (tool === 'find_crews') {
return take<Record<string, string>>(d.crews).map((c, i) => ({
key: `${c.url}-${i}`,
title: c.name || 'Crew',
subtitle: c.location,
image: c.image,
to: toAppPath(c.url),
}));
}
return [];
}
function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) {
const items = toReceiptItems(tool, data);
if (items.length === 0) return null;
return (
<div className="mt-2 space-y-1.5">
{items.map((item) => (
<Link
key={item.key}
to={item.to}
onClick={onNavigate}
className="group flex items-center gap-2.5 rounded-xl border border-teal-500/20 bg-white/[0.03] p-2 transition-all hover:border-teal-500/50 hover:bg-white/[0.06]"
>
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
{item.image ? (
<img src={item.image} alt="" className="h-full w-full object-cover" />
) : (
<MapPin className="h-4 w-4 text-teal-400" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{item.title}</p>
{item.subtitle && <p className="truncate text-xs text-gray-400">{item.subtitle}</p>}
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-gray-600 group-hover:text-teal-400" />
</Link>
))}
</div>
);
}
function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () => void }) {
const isAgent = m.role === 'agent';
return (
<div className={cn('flex gap-2.5', !isAgent && 'flex-row-reverse')}>
{isAgent && (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-xs">🐝</AvatarFallback>
</Avatar>
)}
<div className={cn('flex max-w-[82%] flex-col', !isAgent && 'items-end')}>
<div
className={cn(
'rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words',
isAgent ? 'bg-muted rounded-tl-sm' : 'bg-teal-500 text-black rounded-tr-sm',
)}
>
{m.text}
</div>
{isAgent && m.receipts && (
<ReceiptCards tool={m.receipts.tool} data={m.receipts.data} onNavigate={onNavigate} />
)}
</div>
</div>
);
}
const SUGGESTIONS = [
'Best spots near us?',
'Any events this month?',
'Find a filmer nearby',
"What's the weather for skating?",
];
export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) {
const { messages, isThinking, send } = useCrewAgentChat(ctx);
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, isThinking]);
const submit = (text: string) => {
const t = text.trim();
if (!t || isThinking) return;
setDraft('');
send(t);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
<DialogTitle className="flex items-center gap-2 text-white">
<span className="text-lg leading-none">🐝</span> Ask Sasquatch
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
Crew Agent
</span>
</DialogTitle>
<DialogDescription className="text-xs">
Your crew's AI guide — spots, events, coaches & gear, with live data.
</DialogDescription>
</DialogHeader>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[240px]">
{messages.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<Avatar className="h-12 w-12">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300">🐝</AvatarFallback>
</Avatar>
<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.
</p>
<div className="flex flex-wrap justify-center gap-1.5">
{SUGGESTIONS.map((s) => (
<button
key={s}
onClick={() => submit(s)}
className="rounded-full border border-teal-500/30 bg-teal-500/10 px-3 py-1.5 text-xs font-semibold text-teal-300 transition-colors hover:bg-teal-500/20"
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((m) => (
<MessageRow key={m.id} m={m} onNavigate={() => onOpenChange(false)} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-400" />
Sasquatch is thinking…
</div>
)}
</div>
<div className="border-t border-white/5 p-3">
<form
onSubmit={(e) => {
e.preventDefault();
submit(draft);
}}
className="flex items-center gap-2"
>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ask about your local scene…"
maxLength={300}
className="flex-1"
/>
<Button
type="submit"
size="icon"
disabled={isThinking || !draft.trim()}
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
>
{isThinking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</form>
<p className="mt-1.5 flex items-center justify-center gap-1 text-[10px] text-muted-foreground">
<Sparkles className="h-2.5 w-2.5" /> AI can be wrong — links go to real listings.
</p>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -123,6 +123,7 @@ export function CrewForm({ existing }: CrewFormProps) {
);
const [customVibeInput, setCustomVibeInput] = useState('');
const [focus, setFocus] = useState<CrewFocus>(existing?.focus ?? 'mixed');
const [agentEnabled, setAgentEnabled] = useState(existing?.agentEnabled ?? false);
const [founded, setFounded] = useState(existing?.founded ?? '');
const [image, setImage] = useState<string | null>(existing?.image ?? null);
const [website, setWebsite] = useState(existing?.website ?? '');
@ -281,6 +282,8 @@ export function CrewForm({ existing }: CrewFormProps) {
for (const v of customVibes) tags.push(['t', v]);
// Lab scene focus — only tagged when not the default "mixed" crew.
if (focus !== 'mixed') tags.push(['t', CREW_FOCUS_TAG[focus]]);
// Crew AI agent — queryable marker tag when enabled.
if (agentEnabled) tags.push(['t', 'crew-agent']);
if (founded.trim()) tags.push(['founded', founded.trim()]);
if (image) tags.push(['image', image]);
if (website.trim()) tags.push(['website', website.trim()]);
@ -447,6 +450,41 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
</div>
{/* Crew AI agent (Sasquatch) */}
<div className="space-y-3 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Label className="flex items-center gap-2 text-teal-400">
<span className="text-base leading-none">🐝</span> 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>
</Label>
<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
about nearby spots, events, coaches and gear — it answers with live data and real links. Runs on your
own device; no extra setup.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={agentEnabled}
aria-label="Enable Crew Agent"
onClick={() => setAgentEnabled((v) => !v)}
className={cn(
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
agentEnabled ? 'bg-teal-500' : 'bg-gray-700'
)}
>
<span
className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
agentEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'
)}
/>
</button>
</div>
</div>
{/* Members */}
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>

View file

@ -0,0 +1,154 @@
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 };
}
export interface CrewContext {
crewName: string;
location?: string;
/** City derived from location (best-effort) — helps seed CVM queries. */
city?: string;
}
function buildCrewSystemPrompt(ctx: CrewContext): string {
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.
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}.
## Tools — query live data. Respond with ONLY a JSON tool call when you need 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. Data questions (spots/events/coaches/gear/stats/weather) → ALWAYS use a tool, never guess.
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.
3. Be concise and skater-friendly. Never invent spot names, dates, or user data.
4. 0 results = honest "none listed yet" + encourage adding content at skate-spots.org.`;
}
/**
* Crew-scoped Sasquatch chat (Phase 2). Reuses the CVM data tools and the
* shared AI brain, seeded with the crew's context. Ephemeral/local — no keys,
* no publishing (that's Phase 3). 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 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. 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 };
}

103
src/lib/crewAgent.ts Normal file
View file

@ -0,0 +1,103 @@
/**
* Crew Agent identity (Phase 0)
* ─────────────────────────────
* The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew.
* 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
* storing a key anywhere.
*
* ⚠️ SECURITY TRADEOFF (documented, deliberate for v1):
* Because the key is derived from public data (the crew coordinate) plus a
* fixed, public salt in this source, ANYONE who reads this code can derive the
* same private key. This is acceptable for v1 because the agent only ever:
* - publishes its own kind-0 profile, and
* - (later, Phase 3) posts helper replies into its own crew's chat.
* It holds no funds and speaks only for itself. It is NOT a secure identity and
* must never be trusted for signing anything sensitive. A future version can
* swap this for a founder-controlled stored key without changing call sites.
*/
import { getPublicKey, finalizeEvent, nip19, type EventTemplate } from 'nostr-tools';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { SKATE_CREW_KIND } from '@/lib/types';
/** Public, fixed salt — versioned so we can rotate the scheme later if needed. */
const AGENT_KEY_SALT = 'skatespots:crew-agent:v1';
export interface CrewAgentIdentity {
/** 32-byte private key (hex). */
secretKeyHex: string;
/** 32-byte private key (bytes) — for signing. */
secretKey: Uint8Array;
/** Public key (hex). */
pubkey: string;
/** npub encoding of the public key. */
npub: string;
}
/**
* Build the crew's addressable coordinate `kind:pubkey:identifier`.
* This is the stable, public seed for the agent identity.
*/
export function crewCoord(crewPubkey: string, identifier: string): string {
return `${SKATE_CREW_KIND}:${crewPubkey}:${identifier}`;
}
/**
* Deterministically derive the crew agent's keypair from the crew coordinate.
* Same crew → same agent identity, everywhere, with no stored state.
*/
export function deriveCrewAgentIdentity(crewPubkey: string, identifier: string): CrewAgentIdentity {
const coord = crewCoord(crewPubkey, identifier);
// 32-byte secret = sha256(salt | coord). secp256k1 accepts any 32-byte scalar
// in range; sha256 output is overwhelmingly within range, and nostr-tools /
// noble will reject the astronomically unlikely invalid case at sign time.
const secretKey = sha256(new TextEncoder().encode(`${AGENT_KEY_SALT}|${coord}`));
const secretKeyHex = bytesToHex(secretKey);
const pubkey = getPublicKey(secretKey);
const npub = nip19.npubEncode(pubkey);
return { secretKeyHex, secretKey, pubkey, npub };
}
/** Just the agent pubkey — cheap when you don't need to sign. */
export function crewAgentPubkey(crewPubkey: string, identifier: string): string {
return deriveCrewAgentIdentity(crewPubkey, identifier).pubkey;
}
/** Sasquatch mascot avatar used for the agent's profile. */
export const CREW_AGENT_AVATAR =
'https://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx.blossom.band/2ff0cb5bd8622fed54a80784f5cc9d1f569714e9a16de65857a727e63404f5c2.png';
/**
* Build (unsigned) the agent's kind-0 profile metadata for a crew.
* `bot: true` follows NIP-24 so clients can honestly label it as automated.
*/
export function buildCrewAgentProfile(crewName: string): EventTemplate {
const metadata = {
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. 🛹`,
picture: CREW_AGENT_AVATAR,
bot: true,
};
return {
kind: 0,
content: JSON.stringify(metadata),
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
}
/**
* Sign an event template AS the crew agent (using its derived key).
* Used to publish the agent's profile now, and its chat replies in Phase 3.
*/
export function signAsCrewAgent(
crewPubkey: string,
identifier: string,
template: EventTemplate,
) {
const { secretKey } = deriveCrewAgentIdentity(crewPubkey, identifier);
return finalizeEvent(template, secretKey);
}

61
src/lib/sasquatchBrain.ts Normal file
View file

@ -0,0 +1,61 @@
/**
* Sasquatch brain — the shared AI caller used by both the global Sasquatch
* guide and the per-crew Crew Agent. Kept framework-agnostic (no React) so any
* surface can reuse the same model list + fallback behaviour.
*/
// ─── OpenRouter free models — no credits, no NIP-98, works for everyone ───────
const OPENROUTER_API_KEY = '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';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
// Try multiple free models in order — if one is rate-limited, fall back.
export const FREE_MODELS = [
'openai/gpt-oss-20b:free',
'google/gemma-4-31b-it:free',
'meta-llama/llama-3.3-70b-instruct:free',
];
export type ChatTurn = { role: 'user' | 'assistant'; content: string };
/** Call the AI with model fallback. Throws only if every model fails. */
export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise<string> {
let lastError = '';
for (const model of FREE_MODELS) {
try {
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
});
if (!response.ok) {
const err = await response.text();
console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err);
lastError = `${response.status}: ${err}`;
continue;
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? '';
if (content) return content;
} catch (err) {
console.warn(`[Sasquatch] model ${model} threw:`, err);
lastError = String(err);
}
}
throw new Error(`All AI models failed. Last error: ${lastError}`);
}
/** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */
export function parseToolCall(aiText: string): { tool: string; args: Record<string, unknown> } | null {
const jsonMatch = aiText.match(/\{[\s\S]*"tool"\s*:\s*"(\w+)"[\s\S]*\}/);
if (!jsonMatch) return null;
try {
return JSON.parse(jsonMatch[0]);
} catch {
return null;
}
}

View file

@ -945,6 +945,7 @@ export interface SkateCrew {
image?: string; // Crew logo / photo
vibes: string[]; // Style/focus tags
focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed'
agentEnabled: boolean; // Whether the crew's AI agent ("🐝 Sasquatch") is on
founded?: string; // Year founded (cosmetic)
website?: string;
instagram?: string;
@ -1468,13 +1469,18 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const image = event.tags.find(([name]) => name === 'image')?.[1];
// Vibes are `t` tags that aren't the crew marker or a crew-focus scene tag.
// Vibes are `t` tags that aren't the crew marker, a crew-focus scene tag, or
// the crew-agent marker.
const vibes = event.tags
.filter(([name, value]) => name === 't' && value !== 'skatecrew' && !CREW_FOCUS_FROM_TAG[value])
.filter(([name, value]) =>
name === 't' && value !== 'skatecrew' && value !== 'crew-agent' && !CREW_FOCUS_FROM_TAG[value]
)
.map(([, value]) => value);
// Lab scene focus — first matching crew-focus `t` tag wins, else 'mixed'.
const focusTag = event.tags.find(([name, value]) => name === 't' && !!CREW_FOCUS_FROM_TAG[value])?.[1];
const focus: CrewFocus = focusTag ? CREW_FOCUS_FROM_TAG[focusTag] : 'mixed';
// Crew AI agent enabled when the `crew-agent` marker tag is present.
const agentEnabled = event.tags.some(([name, value]) => name === 't' && value === 'crew-agent');
const founded = event.tags.find(([name]) => name === 'founded')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
@ -1504,6 +1510,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
image,
vibes,
focus,
agentEnabled,
founded,
website,
instagram,

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
@ -13,6 +14,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import {
@ -123,6 +125,7 @@ export default function CrewDetailPage() {
const author = useAuthor(crew?.pubkey);
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false);
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
@ -345,6 +348,31 @@ export default function CrewDetailPage() {
</CardContent>
</Card>
{/* Crew Agent — AI guide, shown when the crew enabled it */}
{crew.agentEnabled && (
<button
type="button"
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]"
>
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-teal-500/15 text-2xl">
🐝
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-black text-white">
Ask Sasquatch
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
Crew Agent
</span>
</p>
<p className="text-xs text-gray-400">
Your crew's AI guide — nearby spots, events, coaches & gear, with live data.
</p>
</div>
<MessageSquare className="h-5 w-5 flex-shrink-0 text-teal-400/60 group-hover:text-teal-400" />
</button>
)}
{/* Crew Chat — private E2E-encrypted group chat for members */}
<CrewChat
crewName={crew.name}
@ -479,6 +507,15 @@ export default function CrewDetailPage() {
</div>
</div>
</div>
{/* Crew Agent chat panel */}
{crew.agentEnabled && (
<CrewAgentPanel
open={agentOpen}
onOpenChange={setAgentOpen}
ctx={{ crewName: crew.name, location: crew.location, city: crew.location }}
/>
)}
</Layout>
);
}