Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ===========================================================================
Expand Down
110 changes: 99 additions & 11 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -34,28 +38,61 @@ export default createPlugin({
contract,

initialize: (config): Effect.Effect<PluginDeps, Error, Scope.Scope> => {
console.log("[API] Initialize called with config:", {
dbUrl: config.secrets.API_DATABASE_URL,
hasApiKey: !!config.secrets.NEAR_AI_API_KEY,
model: config.variables.NEAR_AI_MODEL,
});

return Effect.gen(function* () {
console.log("[API] Creating database layer...");
const dbLayer = DatabaseLive(
config.secrets.API_DATABASE_URL,
config.secrets.API_DATABASE_AUTH_TOKEN
);
const db = yield* Effect.provide(DatabaseContext, dbLayer);
console.log("[API] Database initialized");

// Initialize NEAR service
console.log("[API] Creating 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);
console.log("[API] NEAR service initialized");

// Initialize agent service using Effect Layer
// Initialize agent service with NEAR service
console.log("[API] Creating agent 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] Agent service initialized");

console.log("[API] Plugin initialized");
console.log("[API] Plugin initialized successfully");

return {
db,
agentService,
nearService,
};
});
}).pipe(
Effect.tapError((error) =>
Effect.sync(() => {
console.error("[API] Initialize FAILED with error:", error);
console.error("[API] Error type:", typeof error);
console.error("[API] Error constructor:", error?.constructor?.name);
if (error instanceof Error) {
console.error("[API] Error message:", error.message);
console.error("[API] Error stack:", error.stack);
}
})
)
);
},

shutdown: (_context) =>
Expand All @@ -64,10 +101,15 @@ export default createPlugin({
}),

createRouter: (context, builder) => {
const { agentService, db } = context;
const { agentService, db, nearService } = context;
const isDev = process.env.NODE_ENV !== "production";

const requireAuth = builder.middleware(async ({ context, next }) => {
if (!context.nearAccountId) {
// In dev mode, fall back to DEV_USER if no context is provided
// This is needed because every-plugin dev server doesn't extract context from headers
const nearAccountId = context.nearAccountId || (isDev ? (process.env.DEV_USER || "test.near") : undefined);

if (!nearAccountId) {
throw new ORPCError("UNAUTHORIZED", {
message: "Authentication required",
data: { authType: "nearAccountId" },
Expand All @@ -76,28 +118,33 @@ export default createPlugin({
return next({
context: {
...context,
nearAccountId: context.nearAccountId,
nearAccountId,
db,
},
});
});

const requireAdmin = builder.middleware(async ({ context, next }) => {
if (!context.nearAccountId) {
// In dev mode, fall back to DEV_USER if no context is provided
const nearAccountId = context.nearAccountId || (isDev ? (process.env.DEV_USER || "test.near") : undefined);

if (!nearAccountId) {
throw new ORPCError("UNAUTHORIZED", {
message: "Authentication required",
data: { authType: "nearAccountId" },
});
}
if (context.role !== "admin") {
// In dev mode, treat DEV_USER as admin
const role = context.role || (isDev ? "admin" : undefined);
if (role !== "admin") {
throw new ORPCError("FORBIDDEN", {
message: "Admin role required",
});
}
return next({
context: {
...context,
nearAccountId: context.nearAccountId,
nearAccountId,
db,
},
});
Expand Down Expand Up @@ -154,6 +201,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
// ===========================================================================
Expand Down
104 changes: 99 additions & 5 deletions api/src/services/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -102,13 +101,104 @@ export class AgentService {
constructor(
private db: DrizzleDatabase,
private config: AgentConfig,
private nearService: NearService | null,
) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl,
});
}

// ===========================================================================
// SYSTEM PROMPT GENERATION
// ===========================================================================

/**
* Get dynamic system prompt based on user's NFT rank
*/
private async getSystemPrompt(nearAccountId: string): Promise<string> {
// 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
// ===========================================================================
Expand Down Expand Up @@ -141,14 +231,17 @@ 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)],
limit: 20,
});

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,
Expand Down Expand Up @@ -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<AgentContext, never, never> => {
if (!config.apiKey) {
console.log("[AgentService] API key not provided - service unavailable");
Expand All @@ -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);
};
3 changes: 3 additions & 0 deletions api/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading