From a37224d8a29087f6e875c712f78e48e40c2825fa Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 29 Jul 2026 23:21:37 +0700 Subject: [PATCH] Sasquatch Pro Phase 1: read-only personal assistant tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sasquatch can now answer questions about the logged-in user's OWN data ("what's in my stash?", "my crews", "events near my crew", "my spots", "what am I selling?"). - New usePersonalContext hook: client-side getters for the user's Stash (decrypted locally), crews (founder/member), marketplace listings, and created spots. Reads own data only β€” nothing personal leaves the device. - Registered 4 personal tools (get_my_stash / get_my_crews / get_my_listings / get_my_spots) in the Sasquatch tool switch. - Personal-tools prompt block is appended to the system prompt ONLY when a user is logged in, so logged-out UX is unchanged. - needsLiveData now routes "my/mine" questions to the AI tool path. Phase 1 is intentionally ungated so we can validate the experience before wrapping the Pro paywall (Phase 3). Co-authored-by: shakespeare.diy --- src/components/SasquatchGuide.tsx | 53 +++++++++- src/hooks/usePersonalContext.ts | 170 ++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 src/hooks/usePersonalContext.ts diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index cc57213..17909c7 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -15,6 +15,7 @@ import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-r import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCVMClient } from '@/hooks/useCVMClient'; +import { usePersonalContext } from '@/hooks/usePersonalContext'; import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { answerFromGlossary } from '@/lib/glossary'; import { cn } from '@/lib/utils'; @@ -175,6 +176,31 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}} - goskatespots.nostr1.com = primary community relay - 100% free, no ads, no tracking, community-funded via Geyser (see Supporters). πŸ›Ή`; +// ─── Personal tools (Sasquatch Pro) β€” only offered when the user is logged in ─ +// These read the user's OWN data client-side (Stash is decrypted locally). +const PERSONAL_TOOLS_PROMPT = ` + +## Personal tools (logged-in user's OWN data β€” use for "my/mine" questions) +### get_my_stash +The user's private gear inventory (decks, trucks, wheels, shoes…). Args: none. +Use for "what's in my stash", "what gear do I have", "what am I selling". +Example: {"tool":"get_my_stash","args":{}} + +### get_my_crews +Crews the user founded or is a member of. Args: none. +Use for "my crews", "events near my crew" (call this first to get the crew's location, then find_events there). +Example: {"tool":"get_my_crews","args":{}} + +### get_my_listings +The user's active marketplace/gear listings. Args: none. +Example: {"tool":"get_my_listings","args":{}} + +### get_my_spots +Skate spots the user created/added. Args: none. +Example: {"tool":"get_my_spots","args":{}} + +Rule: for "my/mine" questions ALWAYS use a personal tool β€” never guess about the user's data.`; + // ─── Detect if a question needs live CVM data ───────────────────────────────── function needsLiveData(question: string): boolean { @@ -185,6 +211,9 @@ function needsLiveData(question: string): boolean { 'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count', 'list', 'nearby', 'close to', 'around', 'available', 'crews in', 'crews near', 'find crew', 'find a crew', 'crews around', + // Personal (Sasquatch Pro) β€” route to AI so it picks a get_my_* tool + 'my stash', 'my gear', 'my crew', 'my crews', 'my spots', 'my listings', + 'in my stash', "what am i selling", 'what do i own', 'my inventory', ]; return liveKeywords.some((kw) => q.includes(kw)); } @@ -307,6 +336,7 @@ function ChatText({ text }: { text: string }) { export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const { user } = useCurrentUser(); const cvm = useCVMClient(); + const personal = usePersonalContext(); const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false); const [isVisible, setIsVisible] = useState(false); @@ -467,9 +497,13 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { let aiSucceeded = false; let usedCVM = false; try { - // Build messages with system prompt embedded in first user message + // Build messages with system prompt embedded in first user message. + // Personal tools are only offered when the user is logged in. + const systemPrompt = personal.available + ? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}` + : SYSTEM_PROMPT; const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [ - { role: 'user', content: `${SYSTEM_PROMPT}\n\n---\n\nUser: ${question}` }, + { role: 'user', content: `${systemPrompt}\n\n---\n\nUser: ${question}` }, ...history.slice(0, -1).map((m) => ({ role: m.role, content: m.content })), ]; @@ -518,6 +552,19 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { toolResult = { content: [{ type: 'text', text: weatherJson }] }; break; } + // ── Personal tools (Sasquatch Pro) β€” require login ────────────── + case 'get_my_stash': + toolResult = await personal.getMyStash(); + break; + case 'get_my_crews': + toolResult = await personal.getMyCrews(); + break; + case 'get_my_listings': + toolResult = await personal.getMyListings(); + break; + case 'get_my_spots': + toolResult = await personal.getMySpots(); + break; default: toolResult = { error: `Unknown tool: ${toolName}` }; } @@ -589,7 +636,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, user, cvm, appendMessage, cvmConnected]); + }, [userInput, isThinking, chatMessages, user, cvm, personal, appendMessage, cvmConnected]); // Listen for external events from FAQ/About pages useEffect(() => { diff --git a/src/hooks/usePersonalContext.ts b/src/hooks/usePersonalContext.ts new file mode 100644 index 0000000..cf407e6 --- /dev/null +++ b/src/hooks/usePersonalContext.ts @@ -0,0 +1,170 @@ +/** + * usePersonalContext β€” Sasquatch Pro "personal assistant" data layer. + * ───────────────────────────────────────────────────────────────────────────── + * Read-only access to the LOGGED-IN user's own data so Sasquatch can answer + * questions like "what's in my stash?" or "events near my crew?". + * + * PRIVACY: everything here reads the user's OWN data client-side. The encrypted + * Stash is decrypted locally by the signer (never sent to any AI/server). These + * getters only run when a user is logged in; otherwise they return empty/guards. + * + * Mirrors the useCVMClient shape (async getters returning plain JSON) so the + * Sasquatch tool switch can call them the same way as the public tools. + */ + +import { useCallback } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useStash, type StashItem } from '@/hooks/useStash'; +import { + MARKETPLACE_ITEM_KIND, + SKATE_SPOT_KIND, + SKATE_CREW_KIND, + parseSkateCrew, + type SkateCrew, +} from '@/lib/types'; + +const NOT_LOGGED_IN = { error: 'not_logged_in', message: 'Log in to use personal features.' } as const; + +function getTag(tags: string[][], name: string): string | undefined { + return tags.find((t) => t[0] === name)?.[1]; +} + +function getTags(tags: string[][], name: string): string[] { + return tags.filter((t) => t[0] === name).map((t) => t[1]).filter(Boolean); +} + +/** Latest-wins dedupe for addressable events by pubkey+kind+d. */ +function dedupeAddressable( + events: T[], +): T[] { + const seen = new Map(); + for (const e of events) { + const key = `${e.pubkey}:${e.kind}:${getTag(e.tags, 'd') ?? ''}`; + const cur = seen.get(key); + if (!cur || e.created_at > cur.created_at) seen.set(key, e); + } + return [...seen.values()]; +} + +export function usePersonalContext() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { stash } = useStash(); + + const pubkey = user?.pubkey; + + /** Whether personal tools can run at all. */ + const available = !!pubkey; + + // ── My Stash (already decrypted client-side by useStash) ────────────────── + const getMyStash = useCallback(async () => { + if (!pubkey) return NOT_LOGGED_IN; + const items = stash?.items ?? []; + const byCategory: Record = {}; + for (const it of items) byCategory[it.category] = (byCategory[it.category] ?? 0) + 1; + + const summary = (it: StashItem) => ({ + title: it.title, + category: it.category, + brand: it.brand, + size: it.size, + condition: it.condition, + willingToSell: it.willingToSell, + willingToTrade: it.willingToTrade, + minPrice: it.minPrice, + storageLocation: it.storageLocation, + visibility: it.visibility, + itemType: it.itemType ?? 'stash', + }); + + return { + count: items.length, + by_category: byCategory, + for_sale: items.filter((i) => i.willingToSell).map(summary), + items: items.map(summary), + }; + }, [pubkey, stash]); + + // ── My Crews (founder or confirmed member) ──────────────────────────────── + const getMyCrews = useCallback(async () => { + if (!pubkey) return NOT_LOGGED_IN; + const signal = AbortSignal.timeout(8000); + const raw = await nostr.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }); + const crews = dedupeAddressable(raw) + .map((e) => parseSkateCrew(e)) + .filter((c): c is SkateCrew => !!c) + .filter((c) => c.pubkey === pubkey || c.members.includes(pubkey)); + + return { + count: crews.length, + crews: crews.map((c) => ({ + name: c.name, + role: c.pubkey === pubkey ? 'founder' : c.admins.includes(pubkey) ? 'admin' : 'member', + location: c.location, + geohash: c.geohash, + members: c.members.length, + vibes: c.vibes, + band: c.bandName, + url: `https://skate-spots.org/crew/${c.pubkey}/${c.identifier}`, + })), + }; + }, [pubkey, nostr]); + + // ── My marketplace listings (gear I'm selling) ──────────────────────────── + const getMyListings = useCallback(async () => { + if (!pubkey) return NOT_LOGGED_IN; + const signal = AbortSignal.timeout(8000); + const raw = await nostr.query( + [{ kinds: [MARKETPLACE_ITEM_KIND], authors: [pubkey], limit: 100 }], + { signal }, + ); + const listings = dedupeAddressable(raw) + // Skip tombstoned/sold-out listings that set status=sold if present. + .filter((e) => getTag(e.tags, 'status') !== 'sold'); + + return { + count: listings.length, + listings: listings.map((e) => ({ + title: getTag(e.tags, 'title') ?? 'Untitled', + price: getTag(e.tags, 'price'), + currency: getTag(e.tags, 'currency'), + condition: getTag(e.tags, 'condition'), + categories: getTags(e.tags, 't'), + url: `https://skate-spots.org/marketplace`, + })), + }; + }, [pubkey, nostr]); + + // ── My spots (spots I created) ──────────────────────────────────────────── + const getMySpots = useCallback(async () => { + if (!pubkey) return NOT_LOGGED_IN; + const signal = AbortSignal.timeout(8000); + const raw = await nostr.query( + [{ kinds: [SKATE_SPOT_KIND], authors: [pubkey], limit: 200 }], + { signal }, + ); + const spots = dedupeAddressable(raw).filter((e) => + e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot'), + ); + + return { + count: spots.length, + spots: spots.map((e) => ({ + name: getTag(e.tags, 'title') ?? getTag(e.tags, 'name') ?? 'Unnamed Spot', + city: getTag(e.tags, 'city'), + country: getTag(e.tags, 'country'), + difficulty: getTag(e.tags, 'difficulty'), + url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e.tags, 'd') ?? ''}`, + })), + }; + }, [pubkey, nostr]); + + return { + available, + getMyStash, + getMyCrews, + getMyListings, + getMySpots, + }; +}