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:
parent
87c710bf4a
commit
2a55d1b3df
9 changed files with 685 additions and 3 deletions
26
NIP.md
26
NIP.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
256
src/components/CrewAgentPanel.tsx
Normal file
256
src/components/CrewAgentPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
154
src/hooks/useCrewAgentChat.ts
Normal file
154
src/hooks/useCrewAgentChat.ts
Normal 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
103
src/lib/crewAgent.ts
Normal 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
61
src/lib/sasquatchBrain.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue