SkateSpots/src/hooks/usePersonalContext.ts
shakespeare.diy a37224d8a2 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>
2026-07-29 23:21:37 +07:00

170 lines
6.2 KiB
TypeScript

/**
* 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,
};
}