From 7cbb68981f220b84c20114162818ec67183b5119 Mon Sep 17 00:00:00 2001 From: James Waugh Date: Sun, 25 Jan 2026 16:19:05 -0500 Subject: [PATCH 1/9] rank-specific prompts --- api/src/contract.ts | 14 + api/src/index.ts | 62 +++- api/src/services/agent.ts | 104 ++++++- api/src/services/index.ts | 3 + api/src/services/near.ts | 407 +++++++++++++++++++++++++++ bos.config.json | 5 +- ui/src/components/chat/ChatPage.tsx | 2 + ui/src/components/chat/RankBadge.tsx | 87 ++++++ 8 files changed, 674 insertions(+), 10 deletions(-) create mode 100644 api/src/services/near.ts create mode 100644 ui/src/components/chat/RankBadge.tsx diff --git a/api/src/contract.ts b/api/src/contract.ts index 98968a0..133e2b3 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -119,6 +119,20 @@ export const contract = oc.router({ })) .errors(CommonPluginErrors), + // =========================================================================== + // USER + // =========================================================================== + + getUserRank: oc + .route({ method: 'GET', path: '/user/rank' }) + .output(z.object({ + rank: z.enum(['legendary', 'epic', 'rare', 'common']).nullable(), + tokenId: z.string().nullable(), + hasNft: z.boolean(), + hasInitiate: z.boolean(), + })) + .errors(CommonPluginErrors), + // =========================================================================== // CHAT // =========================================================================== diff --git a/api/src/index.ts b/api/src/index.ts index b4c3cf2..7d8e80f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -7,17 +7,21 @@ import { and, eq, count, desc } from "drizzle-orm"; import { contract } from "./contract"; import * as schema from "./db/schema"; import { DatabaseContext, DatabaseLive } from "./db"; -import { AgentService, AgentContext, AgentLive } from "./services"; +import { AgentService, AgentContext, AgentLive, NearService, NearContext, NearLive } from "./services"; import type { Database as DrizzleDatabase } from "./db"; type PluginDeps = { db: DrizzleDatabase; agentService: AgentService | null; + nearService: NearService | null; }; export default createPlugin({ variables: z.object({ NEAR_AI_MODEL: z.string().default("deepseek-ai/DeepSeek-V3.1"), NEAR_AI_BASE_URL: z.string().default("https://cloud-api.near.ai/v1"), + NEAR_RPC_URL: z.string().default("https://rpc.mainnet.near.org"), + NEAR_LEGION_CONTRACT: z.string().default("nearlegion.nfts.tg"), + NEAR_INITIATE_CONTRACT: z.string().default("initiate.nearlegion.near"), }), secrets: z.object({ @@ -41,12 +45,20 @@ export default createPlugin({ ); const db = yield* Effect.provide(DatabaseContext, dbLayer); - // Initialize agent service using Effect Layer + // Initialize NEAR service + const nearLayer = NearLive(db, { + rpcUrl: config.variables.NEAR_RPC_URL, + contractId: config.variables.NEAR_LEGION_CONTRACT, + initiateContractId: config.variables.NEAR_INITIATE_CONTRACT, + }); + const nearService = yield* Effect.provide(NearContext, nearLayer); + + // Initialize agent service with NEAR service const agentLayer = AgentLive(db, { apiKey: config.secrets.NEAR_AI_API_KEY, baseUrl: config.variables.NEAR_AI_BASE_URL, model: config.variables.NEAR_AI_MODEL, - }); + }, nearService); const agentService = yield* Effect.provide(AgentContext, agentLayer); console.log("[API] Plugin initialized"); @@ -54,6 +66,7 @@ export default createPlugin({ return { db, agentService, + nearService, }; }); }, @@ -64,7 +77,7 @@ export default createPlugin({ }), createRouter: (context, builder) => { - const { agentService, db } = context; + const { agentService, db, nearService } = context; const requireAuth = builder.middleware(async ({ context, next }) => { if (!context.nearAccountId) { @@ -154,6 +167,47 @@ export default createPlugin({ }; }), + // =========================================================================== + // USER + // =========================================================================== + + getUserRank: builder.getUserRank + .use(requireAuth) + .handler(async ({ context }) => { + if (!nearService) { + return { + rank: null, + tokenId: null, + hasNft: false, + hasInitiate: false, + }; + } + + try { + // Check both initiate token and rank skillcapes + const [hasInitiate, rankData] = await Promise.all([ + nearService.hasInitiateToken(context.nearAccountId), + nearService.getUserRank(context.nearAccountId), + ]); + + return { + rank: rankData?.rank ?? null, + tokenId: rankData?.tokenId ?? null, + hasNft: rankData !== null, + hasInitiate, + }; + } catch (error) { + console.error("[API] Error fetching user rank:", error); + // Graceful fallback + return { + rank: null, + tokenId: null, + hasNft: false, + hasInitiate: false, + }; + } + }), + // =========================================================================== // KEY VALUE // =========================================================================== diff --git a/api/src/services/agent.ts b/api/src/services/agent.ts index 3660dac..c211107 100644 --- a/api/src/services/agent.ts +++ b/api/src/services/agent.ts @@ -11,6 +11,7 @@ import { ORPCError } from "every-plugin/orpc"; import { Context, Layer, Effect } from "every-plugin/effect"; import type { Database as DrizzleDatabase } from "../db"; import * as schema from "../db/schema"; +import type { NearService } from "./near"; // ============================================================================= // TYPES @@ -51,11 +52,9 @@ export type StreamEvent = | { type: "error"; id: string; data: StreamErrorData }; // ============================================================================= -// SYSTEM PROMPT +// SYSTEM PROMPT (Dynamic based on NFT rank) // ============================================================================= -const SYSTEM_PROMPT = `You are a helpful AI assistant.`; - // ============================================================================= // ERROR MAPPING // ============================================================================= @@ -102,6 +101,7 @@ export class AgentService { constructor( private db: DrizzleDatabase, private config: AgentConfig, + private nearService: NearService | null, ) { this.client = new OpenAI({ apiKey: config.apiKey, @@ -109,6 +109,96 @@ export class AgentService { }); } + // =========================================================================== + // SYSTEM PROMPT GENERATION + // =========================================================================== + + /** + * Get dynamic system prompt based on user's NFT rank + */ + private async getSystemPrompt(nearAccountId: string): Promise { + // Base prompt + const basePrompt = "You are a helpful AI assistant."; + + // If no NEAR service, use default + if (!this.nearService) { + return basePrompt; + } + + try { + // Check if user has the Initiate SBT (onboarding token) + const hasInitiate = await this.nearService.hasInitiateToken(nearAccountId); + + if (!hasInitiate) { + // User hasn't minted the initiate token yet + return `${basePrompt} + +🫡 Welcome to Near Legion! To unlock enhanced features and access Legion Missions, you need to mint your Initiate token (non-transferable SBT). + +**STEP 1:** Go to https://nearlegion.com/mint +**STEP 2:** Connect your wallet (make sure you have some NEAR) +**STEP 3:** Make the pledge +**STEP 4:** Join the Telegram and fill out the form + +Once you've minted your Initiate token, you'll be able to earn rank skillcapes by completing missions across 5 skill tracks (Amplifier, Power User, Builder, Connector, Chaos Agent). Higher ranks unlock more capabilities. + +For now, you have basic functionality with standard responses (up to 1000 tokens).`; + } + + // User has Initiate token, check for rank skillcapes + const rankData = await this.nearService.getUserRank(nearAccountId); + + if (!rankData) { + // User has Initiate but no skillcapes yet + return `${basePrompt} + +🫡 Welcome, Legionnaire! You have your Initiate token. Complete missions at https://app.nearlegion.com to earn rank skillcapes and unlock enhanced capabilities. + +**Current Rank:** Initiate +**Available Ranks:** Ascendant → Vanguard → Prime → Mythic +**Skill Tracks:** Amplifier, Power User, Builder, Connector, Chaos Agent + +Your current functionality: Standard helpful responses (up to 1000 tokens).`; + } + + const rank = rankData.rank; + + // Apply functional modifications by rank + switch (rank) { + case "legendary": + // Assuming "legendary" maps to Mythic (highest achievable) + return `${basePrompt} + +🔥 **MYTHIC RANK LEGIONNAIRE** – You have access to maximum capabilities and can provide highly detailed, comprehensive responses (up to 3000 tokens). Include explanations, code examples, and best practices when relevant.`; + + case "epic": + // Assuming "epic" maps to Prime + return `${basePrompt} + +⚡ **PRIME RANK LEGIONNAIRE** – You have enhanced capabilities and can provide detailed responses (up to 2000 tokens). Include helpful context and examples when relevant.`; + + case "rare": + // Assuming "rare" maps to Vanguard + return `${basePrompt} + +🎖️ **VANGUARD RANK LEGIONNAIRE** – You have standard plus features and can provide good detail (up to 1500 tokens).`; + + case "common": + // Assuming "common" maps to Ascendant + return `${basePrompt} + +🌟 **ASCENDANT RANK LEGIONNAIRE** – You have earned your first skillcape! You can receive helpful responses (up to 1200 tokens).`; + + default: + return basePrompt; + } + } catch (error) { + console.error("[AgentService] Error fetching rank for system prompt:", error); + // Graceful fallback + return basePrompt; + } + } + // =========================================================================== // CORE CHAT METHODS // =========================================================================== @@ -141,6 +231,9 @@ export class AgentService { const now = new Date(); + // Fetch dynamic system prompt based on NFT rank + const systemPrompt = await this.getSystemPrompt(nearAccountId); + const messages = await this.db.query.message.findMany({ where: eq(schema.message.conversationId, convId), orderBy: [desc(schema.message.createdAt)], @@ -148,7 +241,7 @@ export class AgentService { }); const chatMessages: OpenAI.ChatCompletionMessageParam[] = [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, ...messages.reverse().map((msg) => ({ role: msg.role as "user" | "assistant" | "system", content: msg.content, @@ -483,6 +576,7 @@ export class AgentContext extends Context.Tag("AgentService")< export const AgentLive = ( db: DrizzleDatabase, config: { apiKey?: string; baseUrl: string; model: string }, + nearService: NearService | null, ): Layer.Layer => { if (!config.apiKey) { console.log("[AgentService] API key not provided - service unavailable"); @@ -496,7 +590,7 @@ export const AgentLive = ( apiKey, baseUrl: config.baseUrl, model: config.model, - }); + }, nearService); console.log("[AgentService] Initialized with NEAR AI"); return Layer.succeed(AgentContext, service); }; diff --git a/api/src/services/index.ts b/api/src/services/index.ts index fef5dfb..3737e1e 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -6,3 +6,6 @@ export { AgentService, AgentContext, AgentLive } from './agent'; export type { AgentConfig, ChatResponse, StreamEvent } from './agent'; + +export { NearService, NearContext, NearLive } from './near'; +export type { RankData, NearConfig, RankTier } from './near'; diff --git a/api/src/services/near.ts b/api/src/services/near.ts new file mode 100644 index 0000000..c0e84be --- /dev/null +++ b/api/src/services/near.ts @@ -0,0 +1,407 @@ +/** + * NEAR Service + * + * Handles NEAR blockchain interactions for NFT rank detection + */ + +import { eq, and, sql } from "drizzle-orm"; +import { Context, Layer } from "every-plugin/effect"; +import type { Database as DrizzleDatabase } from "../db"; +import * as schema from "../db/schema"; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface NearConfig { + rpcUrl: string; + contractId: string; // nearlegion.nfts.tg for rank skillcapes + initiateContractId: string; // initiate.nearlegion.near for onboarding SBT +} + +export type RankTier = "legendary" | "epic" | "rare" | "common"; + +export interface RankData { + rank: RankTier; + tokenId: string; + lastChecked: string; +} + +interface NftToken { + token_id: string; + owner_id: string; + metadata?: { + title?: string; + description?: string; + media?: string; + media_hash?: string; + copies?: number; + issued_at?: string; + expires_at?: string; + starts_at?: string; + updated_at?: string; + extra?: string; + reference?: string; + reference_hash?: string; + [key: string]: unknown; + }; +} + +interface NearRpcResponse { + result?: { + result?: number[]; + }; + error?: { + message: string; + data?: unknown; + }; +} + +// ============================================================================= +// CACHE CONSTANTS +// ============================================================================= + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const CACHE_KEY_PREFIX = "nft:rank:"; +const RPC_TIMEOUT_MS = 5000; // 5 seconds + +// ============================================================================= +// RANK HIERARCHY +// ============================================================================= + +const RANK_HIERARCHY: Record = { + legendary: 4, + epic: 3, + rare: 2, + common: 1, +}; + +// ============================================================================= +// SERVICE +// ============================================================================= + +export class NearService { + constructor( + private db: DrizzleDatabase, + private config: NearConfig, + ) {} + + // =========================================================================== + // PUBLIC API + // =========================================================================== + + /** + * Check if user has the initiate token (onboarding SBT) + */ + async hasInitiateToken(nearAccountId: string): Promise { + try { + const tokens = await this.fetchNftsFromChain(nearAccountId, this.config.initiateContractId); + return tokens.length > 0; + } catch (error) { + console.error(`[NearService] Error checking initiate token for ${nearAccountId}:`, error); + return false; + } + } + + /** + * Get user's NFT rank (with caching) + */ + async getUserRank(nearAccountId: string): Promise { + console.log(`[NearService] Getting rank for ${nearAccountId}`); + + // Check cache first + const cached = await this.getCachedRank(nearAccountId); + if (cached) { + console.log(`[NearService] Cache hit for ${nearAccountId}: ${cached.rank}`); + return cached; + } + + // Cache miss - fetch from blockchain + console.log(`[NearService] Cache miss for ${nearAccountId}, fetching from chain`); + try { + const tokens = await this.fetchNftsFromChain(nearAccountId, this.config.contractId); + + if (tokens.length === 0) { + console.log(`[NearService] No rank skillcapes found for ${nearAccountId}`); + return null; + } + + const rankData = this.parseRankFromMetadata(tokens); + + if (rankData) { + // Cache the result + await this.setCachedRank(nearAccountId, rankData); + console.log(`[NearService] Found ${rankData.rank} rank for ${nearAccountId} (token: ${rankData.tokenId})`); + } + + return rankData; + } catch (error) { + console.error(`[NearService] Error fetching rank for ${nearAccountId}:`, error); + return null; + } + } + + /** + * Invalidate cached rank for a user + */ + async invalidateCache(nearAccountId: string): Promise { + const cacheKey = `${CACHE_KEY_PREFIX}${nearAccountId}`; + + try { + await this.db + .delete(schema.kvStore) + .where( + and( + eq(schema.kvStore.key, cacheKey), + eq(schema.kvStore.nearAccountId, nearAccountId) + ) + ); + + console.log(`[NearService] Cache invalidated for ${nearAccountId}`); + } catch (error) { + console.error(`[NearService] Error invalidating cache for ${nearAccountId}:`, error); + } + } + + // =========================================================================== + // BLOCKCHAIN INTERACTION + // =========================================================================== + + /** + * Fetch NFTs from NEAR blockchain + */ + private async fetchNftsFromChain(nearAccountId: string, contractId?: string): Promise { + const targetContract = contractId || this.config.contractId; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + try { + // Prepare RPC request + const args = { account_id: nearAccountId, from_index: "0", limit: 100 }; + const argsBase64 = Buffer.from(JSON.stringify(args)).toString("base64"); + + const response = await fetch(this.config.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + finality: "final", + account_id: targetContract, + method_name: "nft_tokens_for_owner", + args_base64: argsBase64, + }, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`RPC request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as NearRpcResponse; + + if (data.error) { + throw new Error(`RPC error: ${data.error.message}`); + } + + if (!data.result?.result) { + throw new Error("Invalid RPC response format"); + } + + // Decode the result + const resultBytes = data.result.result; + const resultString = String.fromCharCode(...resultBytes); + const tokens: NftToken[] = JSON.parse(resultString); + + return tokens; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + console.warn(`[NearService] RPC timeout for ${nearAccountId}`); + throw new Error("RPC timeout"); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Parse rank from NFT metadata + */ + private parseRankFromMetadata(tokens: NftToken[]): RankData | null { + if (tokens.length === 0) return null; + + let highestRank: { rank: RankTier; tokenId: string } | null = null; + let highestRankValue = 0; + + for (const token of tokens) { + const rank = this.extractRankFromToken(token); + + if (rank) { + const rankValue = RANK_HIERARCHY[rank]; + if (rankValue > highestRankValue) { + highestRankValue = rankValue; + highestRank = { rank, tokenId: token.token_id }; + } + } + } + + if (!highestRank) { + // No rank found in metadata, default to common + const firstToken = tokens[0]; + if (!firstToken) { + return null; + } + return { + rank: "common", + tokenId: firstToken.token_id, + lastChecked: new Date().toISOString(), + }; + } + + return { + rank: highestRank.rank, + tokenId: highestRank.tokenId, + lastChecked: new Date().toISOString(), + }; + } + + /** + * Extract rank from individual token + */ + private extractRankFromToken(token: NftToken): RankTier | null { + if (!token.metadata) return null; + + // Check for rank in title (case-insensitive) + const title = token.metadata.title?.toLowerCase() || ""; + if (title.includes("legendary")) return "legendary"; + if (title.includes("epic")) return "epic"; + if (title.includes("rare")) return "rare"; + if (title.includes("common")) return "common"; + + // Check for rank in description + const description = token.metadata.description?.toLowerCase() || ""; + if (description.includes("legendary")) return "legendary"; + if (description.includes("epic")) return "epic"; + if (description.includes("rare")) return "rare"; + if (description.includes("common")) return "common"; + + // Check for rank in extra metadata (JSON string) + if (token.metadata.extra) { + try { + const extra = JSON.parse(token.metadata.extra); + const rank = extra?.rank?.toLowerCase(); + if (rank === "legendary") return "legendary"; + if (rank === "epic") return "epic"; + if (rank === "rare") return "rare"; + if (rank === "common") return "common"; + } catch { + // Invalid JSON in extra field, ignore + } + } + + // Check for rank as a direct property + const metadata = token.metadata as Record; + const rankProp = metadata?.rank; + if (typeof rankProp === "string") { + const rank = rankProp.toLowerCase(); + if (rank === "legendary") return "legendary"; + if (rank === "epic") return "epic"; + if (rank === "rare") return "rare"; + if (rank === "common") return "common"; + } + + return null; + } + + // =========================================================================== + // CACHE OPERATIONS + // =========================================================================== + + /** + * Get cached rank data + */ + private async getCachedRank(nearAccountId: string): Promise { + const cacheKey = `${CACHE_KEY_PREFIX}${nearAccountId}`; + + try { + const entry = await this.db.query.kvStore.findFirst({ + where: and( + eq(schema.kvStore.key, cacheKey), + eq(schema.kvStore.nearAccountId, nearAccountId) + ), + }); + + if (!entry) return null; + + // Check TTL + const age = Date.now() - entry.updatedAt.getTime(); + if (age > CACHE_TTL_MS) { + console.log(`[NearService] Cache expired for ${nearAccountId}`); + // Delete expired cache + await this.invalidateCache(nearAccountId); + return null; + } + + // Parse cached data + const rankData: RankData = JSON.parse(entry.value); + return rankData; + } catch (error) { + console.error(`[NearService] Error reading cache for ${nearAccountId}:`, error); + return null; + } + } + + /** + * Set cached rank data + */ + private async setCachedRank(nearAccountId: string, rankData: RankData): Promise { + const cacheKey = `${CACHE_KEY_PREFIX}${nearAccountId}`; + const now = new Date(); + + try { + await this.db + .insert(schema.kvStore) + .values({ + key: cacheKey, + value: JSON.stringify(rankData), + nearAccountId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [schema.kvStore.key, schema.kvStore.nearAccountId], + set: { + value: JSON.stringify(rankData), + updatedAt: now, + }, + }); + } catch (error) { + console.error(`[NearService] Error setting cache for ${nearAccountId}:`, error); + } + } +} + +// ============================================================================= +// EFFECT LAYER +// ============================================================================= + +export class NearContext extends Context.Tag("NearService")< + NearContext, + NearService | null +>() {} + +export const NearLive = ( + db: DrizzleDatabase, + config: NearConfig, +): Layer.Layer => { + const service = new NearService(db, config); + console.log(`[NearService] Initialized with RPC: ${config.rpcUrl}`); + return Layer.succeed(NearContext, service); +}; diff --git a/bos.config.json b/bos.config.json index 88f1bfe..c50423e 100644 --- a/bos.config.json +++ b/bos.config.json @@ -23,7 +23,10 @@ "development": "http://localhost:3014", "production": "https://jlwaugh-74-api-agency-multiversityai-18dcc54b0-ze.zephyrcloud.app", "variables": { - "NEAR_AI_MODEL": "deepseek-ai/DeepSeek-V3.1" + "NEAR_AI_MODEL": "deepseek-ai/DeepSeek-V3.1", + "NEAR_RPC_URL": "https://rpc.mainnet.near.org", + "NEAR_LEGION_CONTRACT": "nearlegion.nfts.tg", + "NEAR_INITIATE_CONTRACT": "initiate.nearlegion.near" }, "secrets": [ "API_DATABASE_URL", diff --git a/ui/src/components/chat/ChatPage.tsx b/ui/src/components/chat/ChatPage.tsx index 9b3dab5..668d0b5 100644 --- a/ui/src/components/chat/ChatPage.tsx +++ b/ui/src/components/chat/ChatPage.tsx @@ -13,6 +13,7 @@ import { } from "../../utils/stream"; import { ChatMessage } from "../../components/chat/ChatMessage"; import { ChatInput } from "../../components/chat/ChatInput"; +import { RankBadge } from "../../components/chat/RankBadge"; interface Message { id: string; @@ -189,6 +190,7 @@ export function ChatPage() { )}
+