Sasquatch Pro Phase 1: read-only personal assistant tools

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-29 23:21:37 +07:00
parent 5d07524751
commit a37224d8a2
2 changed files with 220 additions and 3 deletions

View file

@ -15,6 +15,7 @@ import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-r
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCVMClient } from '@/hooks/useCVMClient'; import { useCVMClient } from '@/hooks/useCVMClient';
import { usePersonalContext } from '@/hooks/usePersonalContext';
import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { callSasquatchAI } from '@/lib/sasquatchBrain';
import { answerFromGlossary } from '@/lib/glossary'; import { answerFromGlossary } from '@/lib/glossary';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -175,6 +176,31 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}}
- goskatespots.nostr1.com = primary community relay - goskatespots.nostr1.com = primary community relay
- 100% free, no ads, no tracking, community-funded via Geyser (see Supporters). 🛹`; - 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 ───────────────────────────────── // ─── Detect if a question needs live CVM data ─────────────────────────────────
function needsLiveData(question: string): boolean { function needsLiveData(question: string): boolean {
@ -185,6 +211,9 @@ function needsLiveData(question: string): boolean {
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count', 'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
'list', 'nearby', 'close to', 'around', 'available', 'list', 'nearby', 'close to', 'around', 'available',
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around', '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)); return liveKeywords.some((kw) => q.includes(kw));
} }
@ -307,6 +336,7 @@ function ChatText({ text }: { text: string }) {
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const cvm = useCVMClient(); const cvm = useCVMClient();
const personal = usePersonalContext();
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false); const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -467,9 +497,13 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
let aiSucceeded = false; let aiSucceeded = false;
let usedCVM = false; let usedCVM = false;
try { 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 }> = [ 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 })), ...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 }] }; toolResult = { content: [{ type: 'text', text: weatherJson }] };
break; 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: default:
toolResult = { error: `Unknown tool: ${toolName}` }; toolResult = { error: `Unknown tool: ${toolName}` };
} }
@ -589,7 +636,7 @@ Instructions:
setIsThinking(false); setIsThinking(false);
setStreamingText(''); setStreamingText('');
} }
}, [userInput, isThinking, chatMessages, user, cvm, appendMessage, cvmConnected]); }, [userInput, isThinking, chatMessages, user, cvm, personal, appendMessage, cvmConnected]);
// Listen for external events from FAQ/About pages // Listen for external events from FAQ/About pages
useEffect(() => { useEffect(() => {

View file

@ -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<T extends { pubkey: string; kind: number; tags: string[][]; created_at: number }>(
events: T[],
): T[] {
const seen = new Map<string, T>();
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<string, number> = {};
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,
};
}