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:
parent
5d07524751
commit
a37224d8a2
2 changed files with 220 additions and 3 deletions
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
170
src/hooks/usePersonalContext.ts
Normal file
170
src/hooks/usePersonalContext.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue