SkateSpots/src/lib/crewAgent.ts
shakespeare.diy 2a55d1b3df 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>
2026-07-24 19:26:19 +07:00

103 lines
4.2 KiB
TypeScript

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