diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..0b92f277e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "skills"] + path = skills + url = https://github.com/vezuresdotxyz/alphahuman-skills diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..86bdd07dc --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +todo + +- allow skills to be downloaded from the web +- allow skills to be written as text formatted files like SKILL.md +- skills need to specific via JSON-rpc the state changes they make to their state and data files in memory +- skills need to be able to download custom mcp servers from the web diff --git a/skills b/skills new file mode 160000 index 000000000..0701d1517 --- /dev/null +++ b/skills @@ -0,0 +1 @@ +Subproject commit 0701d1517872f06a5cfa280a596ae1b4496d829f diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index bcd1a11b0..b26d75abe 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -1,9 +1,138 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useAppSelector, useAppDispatch } from "../../../store/hooks"; +import { fetchCurrentUser } from "../../../store/userSlice"; import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; import SettingsHeader from "../components/SettingsHeader"; +import { billingApi } from "../../../services/api/billingApi"; +import { openUrl } from "../../../utils/openUrl"; +import type { CurrentPlanData, PlanTier } from "../../../types/api"; +import { + PLANS, + buildPlanId, + displayPrice, + annualSavings, + isUpgrade as checkIsUpgrade, +} from "./billingHelpers"; +// ── Component ─────────────────────────────────────────────────────────── const BillingPanel = () => { const { navigateBack } = useSettingsNavigation(); + const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.user.user); + // Derived from Redux user state + const currentTier: PlanTier = user?.subscription?.plan ?? "FREE"; + const hasActive = user?.subscription?.hasActiveSubscription ?? false; + const planExpiry = user?.subscription?.planExpiry; + const usage = user?.usage; + + // Local state + const [billingInterval, setBillingInterval] = useState<"monthly" | "annual">( + "monthly", + ); + const [paymentMethod, setPaymentMethod] = useState<"card" | "crypto">( + "card", + ); + const [isLoading, setIsLoading] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchasingTier, setPurchasingTier] = useState(null); + const [currentPlanData, setCurrentPlanData] = + useState(null); + const pollRef = useRef | null>(null); + const pollStartRef = useRef(0); + + // Fetch current plan on mount + useEffect(() => { + setIsLoading(true); + billingApi + .getCurrentPlan() + .then(setCurrentPlanData) + .catch(console.error) + .finally(() => setIsLoading(false)); + }, []); + + // When crypto is selected, force annual + useEffect(() => { + if (paymentMethod === "crypto") { + setBillingInterval("annual"); + } + }, [paymentMethod]); + + // Cleanup poll on unmount + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + // ── Poll for plan change after checkout ───────────────────────────── + const startPolling = useCallback(() => { + if (pollRef.current) clearInterval(pollRef.current); + pollStartRef.current = Date.now(); + + pollRef.current = setInterval(async () => { + // Stop after 2 minutes + if (Date.now() - pollStartRef.current > 120_000) { + if (pollRef.current) clearInterval(pollRef.current); + setIsPurchasing(false); + setPurchasingTier(null); + return; + } + + try { + const plan = await billingApi.getCurrentPlan(); + if ( + plan.hasActiveSubscription && + plan.plan !== currentTier + ) { + setCurrentPlanData(plan); + dispatch(fetchCurrentUser()); + setIsPurchasing(false); + setPurchasingTier(null); + if (pollRef.current) clearInterval(pollRef.current); + } + } catch { + // Ignore polling errors + } + }, 5_000); + }, [currentTier, dispatch]); + + // ── Purchase handlers ─────────────────────────────────────────────── + const handleUpgrade = async (tier: PlanTier) => { + if (tier === "FREE" || tier === currentTier) return; + setIsPurchasing(true); + setPurchasingTier(tier); + + try { + if (paymentMethod === "crypto") { + const { hostedUrl } = await billingApi.createCoinbaseCharge( + tier, + "annual", + ); + await openUrl(hostedUrl); + } else { + const planId = buildPlanId(tier, billingInterval); + const { checkoutUrl } = await billingApi.purchasePlan(planId); + if (checkoutUrl) await openUrl(checkoutUrl); + } + startPolling(); + } catch (err) { + console.error("Purchase failed:", err); + setIsPurchasing(false); + setPurchasingTier(null); + } + }; + + const handleManageSubscription = async () => { + try { + const { portalUrl } = await billingApi.createPortalSession(); + await openUrl(portalUrl); + } catch (err) { + console.error("Portal session failed:", err); + } + }; + + // ── JSX ───────────────────────────────────────────────────────────── return (
{ />
-
-
-
+
+ {/* ── Current plan banner ──────────────────────────────── */} +
+
+
+

+ {isLoading + ? "Loading..." + : (currentPlanData?.plan ?? currentTier)}{" "} + Plan +

+ + {hasActive ? "Active" : "Free"} + +
+ + {hasActive && ( + + )} +
+ + {/* Renewal date */} + {hasActive && planExpiry && ( +

+ Renews{" "} + {new Date(planExpiry).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} +

+ )} + + {/* Token usage */} + {usage && ( +
+
+ Daily token usage + + {usage.remainingTokens.toLocaleString()} /{" "} + {usage.dailyTokenLimit.toLocaleString()} remaining + +
+
+
+
+
+ )} +
+ + {/* ── Interval toggle ──────────────────────────────────── */} +
+ + +
+ + {/* ── Plan tier cards ───────────────────────────────────── */} +
+ {PLANS.map((plan) => { + const isCurrent = plan.tier === currentTier; + const isUpgrade = checkIsUpgrade(plan.tier, currentTier); + const savings = annualSavings(plan, billingInterval); + const isThisPurchasing = + isPurchasing && purchasingTier === plan.tier; + + return ( +
+
+
+
+

+ {plan.name} +

+ {isCurrent && ( + + Current + + )} + {savings && ( + + Save {savings}% + + )} +
+
+ + {displayPrice(plan, billingInterval)} + + {plan.tier !== "FREE" && ( + /mo + )} + {plan.tier !== "FREE" && + billingInterval === "annual" && ( + + (billed ${plan.annualPrice}/yr) + + )} +
+
+ + {/* Action button */} + {isUpgrade && ( + + )} +
+ + {/* Features */} +
    + {plan.features.map((f) => ( +
  • + + + + {f.text} +
  • + ))} +
+
+ ); + })} +
+ + {/* ── Purchasing overlay message ────────────────────────── */} + {isPurchasing && ( +
+
+ + + + +

+ Waiting for payment confirmation... Complete checkout in the + browser window that opened. +

+
+
+ )} + + {/* ── Pay with crypto toggle ────────────────────────────── */} +
+
+

+ Pay with Crypto +

+

+ Annual plans only via Coinbase Commerce +

+
+ +
+ + {/* ── Info notice ───────────────────────────────────────── */} +
+
{ strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} - d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" + d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> -
-

- Billing & Subscription -

-

- Manage your subscription, payment methods, and billing history. -

-
- - Coming Soon - +

+ Payments processed securely through Stripe. Crypto payments + available for annual plans via Coinbase. +

diff --git a/src/components/settings/panels/__tests__/billingHelpers.test.ts b/src/components/settings/panels/__tests__/billingHelpers.test.ts new file mode 100644 index 000000000..b822a8a4a --- /dev/null +++ b/src/components/settings/panels/__tests__/billingHelpers.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { + PLANS, + tierIndex, + buildPlanId, + displayPrice, + annualSavings, + isUpgrade, +} from "../billingHelpers"; +import type { PlanMeta, PlanTier } from "../billingHelpers"; + +describe("PLANS", () => { + it("should contain exactly 3 plans", () => { + expect(PLANS).toHaveLength(3); + }); + + it("should have plans in order: FREE, BASIC, PRO", () => { + expect(PLANS[0].tier).toBe("FREE"); + expect(PLANS[1].tier).toBe("BASIC"); + expect(PLANS[2].tier).toBe("PRO"); + }); + + it("should have FREE plan at $0", () => { + const free = PLANS.find((p) => p.tier === "FREE")!; + expect(free.monthlyPrice).toBe(0); + expect(free.annualPrice).toBe(0); + }); + + it("should have BASIC plan at $25/mo and $250/yr", () => { + const basic = PLANS.find((p) => p.tier === "BASIC")!; + expect(basic.monthlyPrice).toBe(25); + expect(basic.annualPrice).toBe(250); + }); + + it("should have PRO plan at $200/mo and $2000/yr", () => { + const pro = PLANS.find((p) => p.tier === "PRO")!; + expect(pro.monthlyPrice).toBe(200); + expect(pro.annualPrice).toBe(2000); + }); + + it("should have features for every plan", () => { + for (const plan of PLANS) { + expect(plan.features.length).toBeGreaterThan(0); + } + }); +}); + +describe("tierIndex", () => { + it("should return 0 for FREE", () => { + expect(tierIndex("FREE")).toBe(0); + }); + + it("should return 1 for BASIC", () => { + expect(tierIndex("BASIC")).toBe(1); + }); + + it("should return 2 for PRO", () => { + expect(tierIndex("PRO")).toBe(2); + }); + + it("should return -1 for unknown tier", () => { + expect(tierIndex("UNKNOWN" as PlanTier)).toBe(-1); + }); +}); + +describe("buildPlanId", () => { + it("should build BASIC_MONTHLY", () => { + expect(buildPlanId("BASIC", "monthly")).toBe("BASIC_MONTHLY"); + }); + + it("should build BASIC_YEARLY", () => { + expect(buildPlanId("BASIC", "annual")).toBe("BASIC_YEARLY"); + }); + + it("should build PRO_MONTHLY", () => { + expect(buildPlanId("PRO", "monthly")).toBe("PRO_MONTHLY"); + }); + + it("should build PRO_YEARLY", () => { + expect(buildPlanId("PRO", "annual")).toBe("PRO_YEARLY"); + }); + + it("should build FREE_MONTHLY (even though not used in practice)", () => { + expect(buildPlanId("FREE", "monthly")).toBe("FREE_MONTHLY"); + }); +}); + +describe("displayPrice", () => { + const basicPlan = PLANS.find((p) => p.tier === "BASIC")!; + const proPlan = PLANS.find((p) => p.tier === "PRO")!; + const freePlan = PLANS.find((p) => p.tier === "FREE")!; + + describe("monthly billing", () => { + it("should return $0 for FREE plan", () => { + expect(displayPrice(freePlan, "monthly")).toBe("$0"); + }); + + it("should return $25 for BASIC plan", () => { + expect(displayPrice(basicPlan, "monthly")).toBe("$25"); + }); + + it("should return $200 for PRO plan", () => { + expect(displayPrice(proPlan, "monthly")).toBe("$200"); + }); + }); + + describe("annual billing", () => { + it("should return $0 for FREE plan", () => { + expect(displayPrice(freePlan, "annual")).toBe("$0"); + }); + + it("should return annual equivalent monthly price for BASIC ($250/12 = $21)", () => { + // $250 / 12 = 20.83, rounded to $21 + expect(displayPrice(basicPlan, "annual")).toBe("$21"); + }); + + it("should return annual equivalent monthly price for PRO ($2000/12 = $167)", () => { + // $2000 / 12 = 166.67, rounded to $167 + expect(displayPrice(proPlan, "annual")).toBe("$167"); + }); + }); + + it("should handle a custom plan correctly", () => { + const custom: PlanMeta = { + tier: "BASIC", + name: "Custom", + monthlyPrice: 50, + annualPrice: 480, + features: [], + }; + expect(displayPrice(custom, "monthly")).toBe("$50"); + // $480 / 12 = $40 + expect(displayPrice(custom, "annual")).toBe("$40"); + }); +}); + +describe("annualSavings", () => { + const basicPlan = PLANS.find((p) => p.tier === "BASIC")!; + const proPlan = PLANS.find((p) => p.tier === "PRO")!; + const freePlan = PLANS.find((p) => p.tier === "FREE")!; + + it("should return null for FREE plan regardless of interval", () => { + expect(annualSavings(freePlan, "annual")).toBeNull(); + expect(annualSavings(freePlan, "monthly")).toBeNull(); + }); + + it("should return null for monthly billing interval", () => { + expect(annualSavings(basicPlan, "monthly")).toBeNull(); + expect(annualSavings(proPlan, "monthly")).toBeNull(); + }); + + it("should calculate savings for BASIC annual", () => { + // Monthly total: $25 * 12 = $300, Annual: $250 + // Savings: ($300 - $250) / $300 = 16.67%, rounded to 17% + expect(annualSavings(basicPlan, "annual")).toBe(17); + }); + + it("should calculate savings for PRO annual", () => { + // Monthly total: $200 * 12 = $2400, Annual: $2000 + // Savings: ($2400 - $2000) / $2400 = 16.67%, rounded to 17% + expect(annualSavings(proPlan, "annual")).toBe(17); + }); + + it("should return null when annual price equals monthly * 12 (no savings)", () => { + const noSavings: PlanMeta = { + tier: "BASIC", + name: "No Savings", + monthlyPrice: 10, + annualPrice: 120, // 10 * 12, no discount + features: [], + }; + expect(annualSavings(noSavings, "annual")).toBeNull(); + }); + + it("should return correct percentage for large discount", () => { + const bigDiscount: PlanMeta = { + tier: "PRO", + name: "Big Discount", + monthlyPrice: 100, + annualPrice: 600, // 50% off + features: [], + }; + expect(annualSavings(bigDiscount, "annual")).toBe(50); + }); +}); + +describe("isUpgrade", () => { + it("should return true when upgrading from FREE to BASIC", () => { + expect(isUpgrade("BASIC", "FREE")).toBe(true); + }); + + it("should return true when upgrading from FREE to PRO", () => { + expect(isUpgrade("PRO", "FREE")).toBe(true); + }); + + it("should return true when upgrading from BASIC to PRO", () => { + expect(isUpgrade("PRO", "BASIC")).toBe(true); + }); + + it("should return false for same tier", () => { + expect(isUpgrade("FREE", "FREE")).toBe(false); + expect(isUpgrade("BASIC", "BASIC")).toBe(false); + expect(isUpgrade("PRO", "PRO")).toBe(false); + }); + + it("should return false when downgrading from PRO to BASIC", () => { + expect(isUpgrade("BASIC", "PRO")).toBe(false); + }); + + it("should return false when downgrading from PRO to FREE", () => { + expect(isUpgrade("FREE", "PRO")).toBe(false); + }); + + it("should return false when downgrading from BASIC to FREE", () => { + expect(isUpgrade("FREE", "BASIC")).toBe(false); + }); +}); diff --git a/src/components/settings/panels/billingHelpers.ts b/src/components/settings/panels/billingHelpers.ts new file mode 100644 index 000000000..7991df385 --- /dev/null +++ b/src/components/settings/panels/billingHelpers.ts @@ -0,0 +1,96 @@ +import type { PlanIdentifier, PlanTier } from "../../../types/api"; + +export interface PlanFeature { + text: string; + included: boolean; +} + +export interface PlanMeta { + tier: PlanTier; + name: string; + monthlyPrice: number; + annualPrice: number; + features: PlanFeature[]; +} + +export const PLANS: PlanMeta[] = [ + { + tier: "FREE", + name: "Free", + monthlyPrice: 0, + annualPrice: 0, + features: [ + { text: "50 messages per prompt", included: true }, + { text: "1 device", included: true }, + { text: "Rate limited", included: true }, + { text: "Blocked during high demand", included: true }, + ], + }, + { + tier: "BASIC", + name: "Basic", + monthlyPrice: 25, + annualPrice: 250, + features: [ + { text: "200 messages per prompt", included: true }, + { text: "1 device", included: true }, + { text: "Rate limited", included: true }, + { text: "Never blocked during high demand", included: true }, + ], + }, + { + tier: "PRO", + name: "Pro", + monthlyPrice: 200, + annualPrice: 2000, + features: [ + { text: "500 messages per prompt", included: true }, + { text: "3 devices", included: true }, + { text: "Unlimited rate", included: true }, + { text: "Never blocked during high demand", included: true }, + ], + }, +]; + +export function tierIndex(tier: PlanTier): number { + return PLANS.findIndex((p) => p.tier === tier); +} + +export function buildPlanId( + tier: PlanTier, + interval: "monthly" | "annual", +): PlanIdentifier { + const suffix = interval === "annual" ? "YEARLY" : "MONTHLY"; + return `${tier}_${suffix}` as PlanIdentifier; +} + +export function displayPrice( + plan: PlanMeta, + billingInterval: "monthly" | "annual", +): string { + if (plan.tier === "FREE") return "$0"; + if (billingInterval === "annual") { + const monthly = Math.round(plan.annualPrice / 12); + return `$${monthly}`; + } + return `$${plan.monthlyPrice}`; +} + +export function annualSavings( + plan: PlanMeta, + billingInterval: "monthly" | "annual", +): number | null { + if (plan.tier === "FREE" || billingInterval !== "annual") return null; + const monthlyTotal = plan.monthlyPrice * 12; + const pct = Math.round( + ((monthlyTotal - plan.annualPrice) / monthlyTotal) * 100, + ); + return pct > 0 ? pct : null; +} + +export function isUpgrade( + targetTier: PlanTier, + currentTier: PlanTier, +): boolean { + return tierIndex(targetTier) > tierIndex(currentTier); +} diff --git a/src/lib/skills/bundled-loader.ts b/src/lib/skills/bundled-loader.ts new file mode 100644 index 000000000..5970e876c --- /dev/null +++ b/src/lib/skills/bundled-loader.ts @@ -0,0 +1,176 @@ +/** + * Bundled Skill Loader — Build-Time Vite Imports + * + * Loads skills from the skills/ submodule using Vite dynamic imports. + * Each skill is imported at build time and registered with the orchestrator. + */ + +import type { SkillDefinition } from "./types"; +import createDebug from "debug"; + +const log = createDebug("app:skills:bundled"); + +/** + * Map of bundled skill IDs to their Vite dynamic import functions. + * Each function returns a module with a default export of the old-format + * skill definition (from the skills/ submodule). + */ +const BUNDLED_SKILL_MODULES: Record< + string, + () => Promise<{ default: BundledSkillExport }> +> = { + "price-tracker": () => + import("../../../skills/skills/price-tracker/skill") as Promise<{ + default: BundledSkillExport; + }>, + "portfolio-analysis": () => + import("../../../skills/skills/portfolio-analysis/skill") as Promise<{ + default: BundledSkillExport; + }>, + "on-chain-lookup": () => + import("../../../skills/skills/on-chain-lookup/skill") as Promise<{ + default: BundledSkillExport; + }>, + "trading-signals": () => + import("../../../skills/skills/trading-signals/skill") as Promise<{ + default: BundledSkillExport; + }>, +}; + +/** + * Shape of a bundled skill's default export (from skills/ submodule). + * This matches the @alphahuman/skill-types SkillDefinition in the submodule, + * which differs from our unified SkillDefinition. + */ +interface BundledSkillExport { + name: string; + description: string; + version: string; + hooks?: { + onLoad?(ctx: unknown): Promise; + onUnload?(ctx: unknown): Promise; + onSessionStart?(ctx: unknown, sessionId: string): Promise; + onSessionEnd?(ctx: unknown, sessionId: string): Promise; + onBeforeMessage?(ctx: unknown, message: string): Promise; + onAfterResponse?(ctx: unknown, response: string): Promise; + onMemoryFlush?(ctx: unknown): Promise; + onTick?(ctx: unknown): Promise; + }; + tools?: Array<{ + definition: { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record; + required?: string[]; + }; + }; + execute(args: Record): Promise<{ content: string; isError?: boolean }>; + }>; + tickInterval?: number; +} + +/** + * Load a bundled skill by ID and convert it to a unified SkillDefinition. + * Returns undefined if the skill is not found or fails to load. + */ +export async function loadBundledSkill( + skillId: string, +): Promise { + const loader = BUNDLED_SKILL_MODULES[skillId]; + if (!loader) { + log("No bundled skill found for ID: %s", skillId); + return undefined; + } + + try { + const module = await loader(); + const exported = module.default; + return convertBundledToUnified(skillId, exported); + } catch (error) { + log( + "Failed to load bundled skill %s: %s", + skillId, + error instanceof Error ? error.message : String(error), + ); + return undefined; + } +} + +/** Get all available bundled skill IDs */ +export function getBundledSkillIds(): string[] { + return Object.keys(BUNDLED_SKILL_MODULES); +} + +/** + * Convert a bundled skill export (old format from submodule) to + * the unified SkillDefinition format. + */ +function convertBundledToUnified( + skillId: string, + exported: BundledSkillExport, +): SkillDefinition { + const def: SkillDefinition = { + manifest: { + id: skillId, + name: exported.name, + description: exported.description, + version: exported.version, + tier: "bundled", + }, + tickInterval: exported.tickInterval, + }; + + // Convert hooks — the bundled skill hooks accept the old SkillContext, + // but we'll pass the new SkillRuntimeContext. Since the old context is + // a subset of the new one, this is forward-compatible. + if (exported.hooks) { + def.hooks = {}; + const hookNames = [ + "onLoad", + "onUnload", + "onSessionStart", + "onSessionEnd", + "onBeforeMessage", + "onAfterResponse", + "onMemoryFlush", + "onTick", + ] as const; + + for (const hookName of hookNames) { + const hookFn = exported.hooks[hookName]; + if (hookFn) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (def.hooks as any)[hookName] = hookFn; + } + } + } + + // Convert tools from old AITool format to SkillToolDefinition + if (exported.tools && exported.tools.length > 0) { + def.tools = exported.tools.map((tool) => ({ + tool: { + name: tool.definition.name, + description: tool.definition.description, + inputSchema: { + type: "object" as const, + properties: tool.definition.parameters.properties, + required: tool.definition.parameters.required, + }, + }, + handler: async (args) => { + const result = await tool.execute(args); + return { + content: [{ type: "text" as const, text: result.content }], + isError: result.isError ?? false, + }; + }, + // Bundled skill tools default to api_read tier + tier: "api_read" as const, + readOnly: false, + })); + } + + return def; +} diff --git a/src/lib/skills/entity-extension.ts b/src/lib/skills/entity-extension.ts new file mode 100644 index 000000000..477a17496 --- /dev/null +++ b/src/lib/skills/entity-extension.ts @@ -0,0 +1,147 @@ +/** + * Entity Extension Registry + * + * Skills register new entity types and relation types here. + * The base types in src/lib/ai/entities/types.ts remain but become extensible + * through this registry. + */ + +import type { + SkillEntityDefinition, + EntityTypeRegistration, + RelationTypeRegistration, + EntityBuilder, +} from "./types"; +import createDebug from "debug"; + +const log = createDebug("app:skills:entities"); + +/** Base entity types from the core platform */ +const BASE_ENTITY_TYPES = new Set([ + "contact", + "chat", + "message", + "email", + "wallet", + "token", + "transaction", +]); + +/** Base relation types from the core platform */ +const BASE_RELATION_TYPES = new Set([ + "member_of", + "sent_by", + "sent_to", + "owns", + "traded", + "replied_to", +]); + +export class EntityExtensionRegistry { + /** Skill-registered entity types, keyed by type name */ + private entityTypes = new Map(); + /** Skill-registered relation types, keyed by type name */ + private relationTypes = new Map(); + /** Entity builders, keyed by skill ID */ + private builders = new Map(); + + /** Register entity types, relation types, and builders for a skill */ + registerSkillEntities(skillId: string, def: SkillEntityDefinition): void { + if (def.entityTypes) { + for (const reg of def.entityTypes) { + this.entityTypes.set(reg.type, { ...reg, skillId }); + log("Registered entity type %s from skill %s", reg.type, skillId); + } + } + + if (def.relationTypes) { + for (const reg of def.relationTypes) { + this.relationTypes.set(reg.type, { ...reg, skillId }); + log("Registered relation type %s from skill %s", reg.type, skillId); + } + } + + if (def.builders && def.builders.length > 0) { + this.builders.set(skillId, def.builders); + log( + "Registered %d entity builders from skill %s", + def.builders.length, + skillId, + ); + } + } + + /** Unregister all entity extensions for a skill */ + unregisterSkillEntities(skillId: string): void { + // Remove entity types from this skill + for (const [type, reg] of this.entityTypes) { + if (reg.skillId === skillId) { + this.entityTypes.delete(type); + } + } + + // Remove relation types from this skill + for (const [type, reg] of this.relationTypes) { + if (reg.skillId === skillId) { + this.relationTypes.delete(type); + } + } + + // Remove builders + this.builders.delete(skillId); + + log("Unregistered entity extensions for skill %s", skillId); + } + + /** Check if an entity type is valid (base or skill-registered) */ + isValidEntityType(type: string): boolean { + return BASE_ENTITY_TYPES.has(type) || this.entityTypes.has(type); + } + + /** Check if a relation type is valid (base or skill-registered) */ + isValidRelationType(type: string): boolean { + return BASE_RELATION_TYPES.has(type) || this.relationTypes.has(type); + } + + /** Get all valid entity types (base + skill-registered) */ + getAllEntityTypes(): string[] { + return [ + ...BASE_ENTITY_TYPES, + ...this.entityTypes.keys(), + ]; + } + + /** Get all valid relation types (base + skill-registered) */ + getAllRelationTypes(): string[] { + return [ + ...BASE_RELATION_TYPES, + ...this.relationTypes.keys(), + ]; + } + + /** Get entity type registrations from skills only */ + getSkillEntityTypes(): EntityTypeRegistration[] { + return Array.from(this.entityTypes.values()); + } + + /** Get relation type registrations from skills only */ + getSkillRelationTypes(): RelationTypeRegistration[] { + return Array.from(this.relationTypes.values()); + } + + /** Get all entity builders, optionally filtered by source */ + getBuilders(source?: string): EntityBuilder[] { + const all = Array.from(this.builders.values()).flat(); + if (source) { + return all.filter((b) => b.source === source); + } + return all; + } + + /** Clear all skill extensions */ + clear(): void { + this.entityTypes.clear(); + this.relationTypes.clear(); + this.builders.clear(); + } +} diff --git a/src/lib/skills/intelligence-engine.ts b/src/lib/skills/intelligence-engine.ts new file mode 100644 index 000000000..7acadd17f --- /dev/null +++ b/src/lib/skills/intelligence-engine.ts @@ -0,0 +1,196 @@ +/** + * Intelligence Engine — Cross-Protocol Data Linking + * + * Evaluates rules from skills for autonomous entity linking and actions. + * + * Event flow: + * 1. Something happens (entity created, message received, state changed) + * 2. fireEvent() is called with event type + data + * 3. Engine checks all registered rules for matching triggers + * 4. Matching rules execute their actions + * 5. Cooldown prevents duplicate firings + */ + +import type { + IntelligenceRule, + IntelligenceActionContext, +} from "./types"; +import type { EntityManager } from "../ai/entities/manager"; +import createDebug from "debug"; + +const log = createDebug("app:skills:intelligence"); + +interface RegisteredRule { + rule: IntelligenceRule; + skillId: string; + lastFiredAt: number; +} + +export class IntelligenceEngine { + private rules = new Map(); + private entityManager: EntityManager | null = null; + + /** Set the entity manager for action execution */ + setEntityManager(entityManager: EntityManager): void { + this.entityManager = entityManager; + } + + /** Register intelligence rules for a skill */ + registerRules(skillId: string, rules: IntelligenceRule[]): void { + for (const rule of rules) { + const id = `${skillId}:${rule.id}`; + this.rules.set(id, { + rule, + skillId, + lastFiredAt: 0, + }); + log("Registered intelligence rule %s from skill %s", rule.id, skillId); + } + } + + /** Unregister all rules for a skill */ + unregisterRules(skillId: string): void { + for (const [id, entry] of this.rules) { + if (entry.skillId === skillId) { + this.rules.delete(id); + } + } + log("Unregistered intelligence rules for skill %s", skillId); + } + + /** + * Fire an event and evaluate matching rules. + * Rules that match the event trigger will have their actions executed. + */ + async fireEvent(eventType: string, data: unknown): Promise { + const now = Date.now(); + const matchingRules: RegisteredRule[] = []; + + for (const entry of this.rules.values()) { + const { rule, lastFiredAt } = entry; + + // Skip disabled rules + if (rule.enabled === false) continue; + + // Check event type match + if (rule.trigger.eventType !== eventType) continue; + + // Check filter conditions + if (rule.trigger.filter) { + const { entityType, source, predicate } = rule.trigger.filter; + const eventData = data as Record; + + if (entityType && eventData.entityType !== entityType) continue; + if (source && eventData.source !== source) continue; + if (predicate && !predicate(data)) continue; + } + + // Check cooldown + if (rule.cooldownMs && now - lastFiredAt < rule.cooldownMs) { + log( + "Rule %s skipped (cooldown: %dms remaining)", + rule.id, + rule.cooldownMs - (now - lastFiredAt), + ); + continue; + } + + matchingRules.push(entry); + } + + // Execute matching rules + for (const entry of matchingRules) { + try { + await this.executeAction(entry.rule, data); + entry.lastFiredAt = now; + log("Rule %s fired for event %s", entry.rule.id, eventType); + } catch (error) { + log( + "Rule %s failed: %s", + entry.rule.id, + error instanceof Error ? error.message : String(error), + ); + } + } + } + + /** Execute a rule's action */ + private async executeAction( + rule: IntelligenceRule, + data: unknown, + ): Promise { + const ctx: IntelligenceActionContext = { + entities: this.entityManager!, + log: (message: string) => log("[rule:%s] %s", rule.id, message), + }; + + const { action } = rule; + + switch (action.type) { + case "create_entity": { + if (!this.entityManager) break; + const entityData = data as Record; + const params = action.params ?? {}; + await this.entityManager.upsert({ + id: (params.id as string) ?? crypto.randomUUID(), + type: (params.entityType as string) ?? (entityData.entityType as string) ?? "contact", + source: (params.source as string) ?? (entityData.source as string) ?? "manual", + sourceId: (params.sourceId as string) ?? null, + title: (params.title as string) ?? null, + summary: (params.summary as string) ?? null, + metadata: params.metadata ? JSON.stringify(params.metadata) : null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + break; + } + + case "create_relation": { + if (!this.entityManager) break; + const params = action.params ?? {}; + await this.entityManager.addRelation({ + id: crypto.randomUUID(), + fromEntityId: params.fromEntityId as string, + toEntityId: params.toEntityId as string, + relationType: (params.relationType as string) ?? "member_of", + metadata: params.metadata ? JSON.stringify(params.metadata) : null, + createdAt: Date.now(), + }); + break; + } + + case "tag_entity": { + if (!this.entityManager) break; + const params = action.params ?? {}; + const entityId = params.entityId as string; + const tag = params.tag as string; + if (entityId && tag) { + await this.entityManager.addTag(entityId, tag); + } + break; + } + + case "custom": { + if (action.handler) { + await action.handler(data, ctx); + } + break; + } + } + } + + /** Get all registered rule IDs */ + getRuleIds(): string[] { + return Array.from(this.rules.keys()); + } + + /** Get count of registered rules */ + get size(): number { + return this.rules.size; + } + + /** Clear all rules */ + clear(): void { + this.rules.clear(); + } +} diff --git a/src/lib/skills/runtime-protocol.ts b/src/lib/skills/runtime-protocol.ts new file mode 100644 index 000000000..b3948cb54 --- /dev/null +++ b/src/lib/skills/runtime-protocol.ts @@ -0,0 +1,271 @@ +/** + * Runtime Protocol — JSON-RPC Protocol Definition for Subprocess Skills + * + * Defines the protocol for subprocess-based runtime skills. + * This is MCP-compatible with skill lifecycle extensions. + * + * Transport: JSON-RPC 2.0 over stdin/stdout (same as MCP stdio transport) + * + * This file only defines types and constants. The actual subprocess host + * is Phase 4 (future). + */ + +// --------------------------------------------------------------------------- +// JSON-RPC Base Types +// --------------------------------------------------------------------------- + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number; + result?: unknown; + error?: JsonRpcError; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +// --------------------------------------------------------------------------- +// Standard MCP Methods (Tools) +// --------------------------------------------------------------------------- + +/** tools/list — List tool definitions provided by this skill */ +export const METHOD_TOOLS_LIST = "tools/list" as const; + +/** tools/call — Execute a tool */ +export const METHOD_TOOLS_CALL = "tools/call" as const; + +export interface ToolsListResult { + tools: Array<{ + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; + }>; +} + +export interface ToolsCallParams { + name: string; + arguments: Record; +} + +export interface ToolsCallResult { + content: Array<{ + type: "text"; + text: string; + }>; + isError?: boolean; +} + +// --------------------------------------------------------------------------- +// Skill Lifecycle Notifications (Custom Extension) +// --------------------------------------------------------------------------- + +/** skill/load — Skill should initialize */ +export const METHOD_SKILL_LOAD = "skill/load" as const; + +/** skill/unload — Skill should clean up */ +export const METHOD_SKILL_UNLOAD = "skill/unload" as const; + +/** skill/activate — Skill is now active */ +export const METHOD_SKILL_ACTIVATE = "skill/activate" as const; + +/** skill/deactivate — Skill is being deactivated */ +export const METHOD_SKILL_DEACTIVATE = "skill/deactivate" as const; + +/** skill/sessionStart — New AI session */ +export const METHOD_SKILL_SESSION_START = "skill/sessionStart" as const; + +/** skill/sessionEnd — Session ended */ +export const METHOD_SKILL_SESSION_END = "skill/sessionEnd" as const; + +/** skill/beforeMessage — Pre-process message (can transform) */ +export const METHOD_SKILL_BEFORE_MESSAGE = "skill/beforeMessage" as const; + +/** skill/afterResponse — Post-process response (can transform) */ +export const METHOD_SKILL_AFTER_RESPONSE = "skill/afterResponse" as const; + +/** skill/tick — Periodic heartbeat */ +export const METHOD_SKILL_TICK = "skill/tick" as const; + +export interface SkillLoadParams { + manifest: { + id: string; + name: string; + version: string; + }; + dataDir: string; +} + +export interface SkillSessionParams { + sessionId: string; +} + +export interface SkillMessageParams { + message: string; +} + +export interface SkillMessageResult { + /** Transformed message, or null to keep original */ + message: string | null; +} + +// --------------------------------------------------------------------------- +// Skill State Methods +// --------------------------------------------------------------------------- + +/** state/get — Read skill's state */ +export const METHOD_STATE_GET = "state/get" as const; + +/** state/set — Write to skill's state */ +export const METHOD_STATE_SET = "state/set" as const; + +export interface StateGetResult { + state: Record; +} + +export interface StateSetParams { + partial: Record; +} + +// --------------------------------------------------------------------------- +// Entity Methods +// --------------------------------------------------------------------------- + +/** entities/register — Register entity types */ +export const METHOD_ENTITIES_REGISTER = "entities/register" as const; + +/** entities/upsert — Create/update an entity */ +export const METHOD_ENTITIES_UPSERT = "entities/upsert" as const; + +/** entities/search — Search entities */ +export const METHOD_ENTITIES_SEARCH = "entities/search" as const; + +/** entities/addRelation — Add a relation between entities */ +export const METHOD_ENTITIES_ADD_RELATION = "entities/addRelation" as const; + +/** entities/addTag — Tag an entity */ +export const METHOD_ENTITIES_ADD_TAG = "entities/addTag" as const; + +export interface EntitiesRegisterParams { + entityTypes?: Array<{ + type: string; + label: string; + description?: string; + }>; + relationTypes?: Array<{ + type: string; + label: string; + description?: string; + }>; +} + +export interface EntitiesUpsertParams { + id?: string; + type: string; + source: string; + sourceId?: string; + title?: string; + summary?: string; + metadata?: Record; +} + +export interface EntitiesSearchParams { + query: string; + types?: string[]; + limit?: number; +} + +export interface EntitiesAddRelationParams { + fromEntityId: string; + toEntityId: string; + relationType: string; + metadata?: Record; +} + +export interface EntitiesAddTagParams { + entityId: string; + tag: string; +} + +// --------------------------------------------------------------------------- +// Intelligence Methods +// --------------------------------------------------------------------------- + +/** intelligence/registerRules — Register intelligence rules */ +export const METHOD_INTELLIGENCE_REGISTER_RULES = + "intelligence/registerRules" as const; + +/** intelligence/emitEvent — Fire a custom event */ +export const METHOD_INTELLIGENCE_EMIT_EVENT = + "intelligence/emitEvent" as const; + +export interface IntelligenceRegisterRulesParams { + rules: Array<{ + id: string; + description: string; + trigger: { + eventType: string; + filter?: { + entityType?: string; + source?: string; + }; + }; + action: { + type: "create_entity" | "create_relation" | "tag_entity" | "custom"; + params?: Record; + }; + cooldownMs?: number; + enabled?: boolean; + }>; +} + +export interface IntelligenceEmitEventParams { + eventType: string; + data: unknown; +} + +// --------------------------------------------------------------------------- +// All Methods (Union) +// --------------------------------------------------------------------------- + +export const ALL_METHODS = [ + METHOD_TOOLS_LIST, + METHOD_TOOLS_CALL, + METHOD_SKILL_LOAD, + METHOD_SKILL_UNLOAD, + METHOD_SKILL_ACTIVATE, + METHOD_SKILL_DEACTIVATE, + METHOD_SKILL_SESSION_START, + METHOD_SKILL_SESSION_END, + METHOD_SKILL_BEFORE_MESSAGE, + METHOD_SKILL_AFTER_RESPONSE, + METHOD_SKILL_TICK, + METHOD_STATE_GET, + METHOD_STATE_SET, + METHOD_ENTITIES_REGISTER, + METHOD_ENTITIES_UPSERT, + METHOD_ENTITIES_SEARCH, + METHOD_ENTITIES_ADD_RELATION, + METHOD_ENTITIES_ADD_TAG, + METHOD_INTELLIGENCE_REGISTER_RULES, + METHOD_INTELLIGENCE_EMIT_EVENT, +] as const; diff --git a/src/lib/skills/state-manager.ts b/src/lib/skills/state-manager.ts new file mode 100644 index 000000000..77e03ecfc --- /dev/null +++ b/src/lib/skills/state-manager.ts @@ -0,0 +1,121 @@ +/** + * Skill State Manager — Zustand Store Per Skill + * + * Each skill gets its own Zustand store (vanilla mode, no React dependency). + * Completely separate from Redux — core app state stays in Redux, + * skill-specific state lives here. + */ + +import { createStore, type StoreApi } from "zustand/vanilla"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { SkillStateDefinition } from "./types"; +import createDebug from "debug"; + +const log = createDebug("app:skills:state"); + +export class SkillStateManager { + private stores = new Map>>(); + + /** + * Create a Zustand store for a skill. + * If a store already exists for this skillId, it is destroyed first. + */ + createStore(skillId: string, definition: SkillStateDefinition): void { + // Destroy existing store if present + if (this.stores.has(skillId)) { + this.destroyStore(skillId); + } + + const initial = definition.initialState as Record; + + if (definition.persist) { + const { name, whitelist, volatileKeys } = definition.persist; + + const store = createStore( + persist>( + () => ({ ...initial }), + { + name: `skill-${name}`, + storage: createJSONStorage(() => localStorage), + partialize: (state) => { + if (whitelist && whitelist.length > 0) { + const partial: Record = {}; + for (const key of whitelist) { + if (key in state) { + partial[key] = state[key]; + } + } + return partial; + } + return state; + }, + onRehydrateStorage: () => { + return (state) => { + if (state && volatileKeys) { + for (const key of volatileKeys) { + if (key in initial) { + state[key] = initial[key]; + } + } + } + }; + }, + }, + ), + ); + + this.stores.set(skillId, store); + } else { + const store = createStore>(() => ({ ...initial })); + this.stores.set(skillId, store); + } + + log("Created store for skill %s", skillId); + } + + /** Get a skill's current state */ + getState(skillId: string): S | undefined { + const store = this.stores.get(skillId); + if (!store) return undefined; + return store.getState() as S; + } + + /** Update a skill's state (shallow merge) */ + setState(skillId: string, partial: Partial): void { + const store = this.stores.get(skillId); + if (!store) return; + store.setState(partial as Partial>); + } + + /** Subscribe to state changes for a skill */ + subscribe( + skillId: string, + listener: (state: unknown) => void, + ): () => void { + const store = this.stores.get(skillId); + if (!store) return () => {}; + return store.subscribe(listener); + } + + /** Destroy a skill's store and clean up */ + destroyStore(skillId: string): void { + const store = this.stores.get(skillId); + if (store) { + store.destroy(); + this.stores.delete(skillId); + log("Destroyed store for skill %s", skillId); + } + } + + /** Check if a store exists for a skill */ + hasStore(skillId: string): boolean { + return this.stores.has(skillId); + } + + /** Destroy all stores */ + destroyAll(): void { + for (const [skillId] of this.stores) { + this.destroyStore(skillId); + } + } +} diff --git a/src/lib/skills/tool-registry.ts b/src/lib/skills/tool-registry.ts new file mode 100644 index 000000000..cc759ffaa --- /dev/null +++ b/src/lib/skills/tool-registry.ts @@ -0,0 +1,156 @@ +/** + * Unified Tool Registry + * + * Replaces three fragmented tool systems: + * - AI skills tools (src/lib/ai/tools/registry.ts) + * - MCP extra tools (src/lib/mcp/skills/index.ts) + * - Telegram MCP tools (src/lib/telegram/server.ts findToolHandler) + * + * All tools from all skills are registered here with their handlers. + * Tool execution flow: + * 1. Look up tool in registry + * 2. Enforce rate limits (delegates to existing rateLimiter.ts) + * 3. Build SkillToolContext for the owning skill + * 4. Call handler + * 5. Return result + */ + +import type { MCPTool, MCPToolResult } from "../mcp/types"; +import type { ToolTier } from "../mcp/rateLimiter"; +import { enforceRateLimit, classifyTool } from "../mcp/rateLimiter"; +import type { + SkillToolDefinition, + SkillToolContext, + UnifiedToolRegistry, +} from "./types"; +import type { SkillStateManager } from "./state-manager"; +import createDebug from "debug"; + +const log = createDebug("app:skills:tools"); + +interface RegisteredTool { + skillId: string; + def: SkillToolDefinition; +} + +export class UnifiedToolRegistryImpl implements UnifiedToolRegistry { + private tools = new Map(); + private stateManager: SkillStateManager; + + constructor(stateManager: SkillStateManager) { + this.stateManager = stateManager; + } + + /** Register all tools for a skill */ + registerTools(skillId: string, tools: SkillToolDefinition[]): void { + for (const def of tools) { + const toolName = def.tool.name; + if (this.tools.has(toolName)) { + log( + "Tool %s already registered by skill %s, overwriting with %s", + toolName, + this.tools.get(toolName)!.skillId, + skillId, + ); + } + this.tools.set(toolName, { skillId, def }); + } + log("Registered %d tools for skill %s", tools.length, skillId); + } + + /** Unregister all tools belonging to a skill */ + unregisterTools(skillId: string): void { + let count = 0; + for (const [name, entry] of this.tools) { + if (entry.skillId === skillId) { + this.tools.delete(name); + count++; + } + } + if (count > 0) { + log("Unregistered %d tools for skill %s", count, skillId); + } + } + + /** List all registered tool definitions */ + listTools(): MCPTool[] { + return Array.from(this.tools.values()).map((entry) => entry.def.tool); + } + + /** + * Execute a tool by name. + * Returns undefined if the tool is not found in this registry. + * Enforces rate limits before execution. + */ + async executeTool( + name: string, + args: Record, + ): Promise { + const entry = this.tools.get(name); + if (!entry) return undefined; + + // Enforce rate limits + const tier = this.getToolTier(name); + if (tier !== "state_only") { + await enforceRateLimit(name); + } + + // Build context for the tool handler + const ctx: SkillToolContext = { + skillId: entry.skillId, + getState: () => + (this.stateManager.getState(entry.skillId) ?? {}) as S, + setState: (partial: Partial) => + this.stateManager.setState(entry.skillId, partial), + log: (message: string) => + log("[%s/%s] %s", entry.skillId, name, message), + }; + + return entry.def.handler(args, ctx); + } + + /** Get the skill that owns a tool */ + getToolOwner(toolName: string): string | undefined { + return this.tools.get(toolName)?.skillId; + } + + /** + * Get the rate limit tier for a tool. + * Uses the skill definition's tier if set, otherwise falls back + * to the existing classifyTool() for Telegram tools. + */ + getToolTier(toolName: string): ToolTier { + const entry = this.tools.get(toolName); + if (entry?.def.tier) return entry.def.tier; + // Fall back to existing rate limiter classification + return classifyTool(toolName); + } + + /** Check if a tool is read-only (safe for ask/plan modes) */ + isReadOnly(toolName: string): boolean { + const entry = this.tools.get(toolName); + if (!entry) return false; + return entry.def.readOnly ?? false; + } + + /** Check if a tool is registered */ + hasTool(toolName: string): boolean { + return this.tools.has(toolName); + } + + /** Get total number of registered tools */ + get size(): number { + return this.tools.size; + } + + /** Get all tool names for a specific skill */ + getToolsForSkill(skillId: string): string[] { + const names: string[] = []; + for (const [name, entry] of this.tools) { + if (entry.skillId === skillId) { + names.push(name); + } + } + return names; + } +} diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts new file mode 100644 index 000000000..ea95b4d43 --- /dev/null +++ b/src/lib/skills/types.ts @@ -0,0 +1,357 @@ +/** + * Unified Skill System — Core Type Definitions + * + * All interfaces for the skill system including lifecycle management, + * tool definitions, state management, entity extensions, and intelligence rules. + */ + +import type { MCPTool, MCPToolResult } from "../mcp/types"; +import type { ToolTier } from "../mcp/rateLimiter"; +import type { EntityType, EntitySource, RelationType } from "../ai/entities/types"; +import type { MemoryManager } from "../ai/memory/manager"; +import type { SessionManager } from "../ai/sessions/manager"; +import type { EntityManager } from "../ai/entities/manager"; +import type { ApiClient } from "../../services/apiClient"; + +// Re-export ToolTier from rateLimiter so skills can reference it +export type { ToolTier }; + +// --------------------------------------------------------------------------- +// Skill Lifecycle +// --------------------------------------------------------------------------- + +/** Skill status lifecycle states */ +export type SkillStatus = + | "registered" + | "loading" + | "initialized" + | "active" + | "deactivating" + | "error" + | "disabled"; + +/** Skill execution tier */ +export type SkillTier = "bundled" | "runtime"; + +// --------------------------------------------------------------------------- +// Skill Manifest +// --------------------------------------------------------------------------- + +/** Skill manifest (metadata) */ +export interface SkillManifest { + /** Unique skill identifier */ + id: string; + /** Human-readable name */ + name: string; + /** Description of what the skill does */ + description: string; + /** Semantic version */ + version: string; + /** Whether this skill is bundled (build-time) or runtime (subprocess) */ + tier: SkillTier; + /** Other skill IDs this depends on */ + dependencies?: string[]; + /** Required platform features */ + capabilities?: string[]; + /** Icon identifier or path */ + icon?: string; + /** Skill author */ + author?: string; + /** Runtime skills only: subprocess configuration */ + runtime?: { + /** Command to execute (e.g., "python3", "deno run") */ + command: string; + /** Arguments to pass (e.g., ["skill.py"]) */ + args?: string[]; + /** Environment variables */ + env?: Record; + }; +} + +// --------------------------------------------------------------------------- +// Lifecycle Hooks +// --------------------------------------------------------------------------- + +/** Enhanced lifecycle hooks that a skill can implement */ +export interface SkillHooks { + /** Called when skill is loaded */ + onLoad?(ctx: SkillRuntimeContext): Promise; + /** Called when skill is unloaded */ + onUnload?(ctx: SkillRuntimeContext): Promise; + /** Called when skill becomes active */ + onActivate?(ctx: SkillRuntimeContext): Promise; + /** Called when skill is being deactivated */ + onDeactivate?(ctx: SkillRuntimeContext): Promise; + /** Called when a new AI session starts */ + onSessionStart?(ctx: SkillRuntimeContext, sessionId: string): Promise; + /** Called when an AI session ends */ + onSessionEnd?(ctx: SkillRuntimeContext, sessionId: string): Promise; + /** Called before the AI processes a user message; can transform it */ + onBeforeMessage?(ctx: SkillRuntimeContext, message: string): Promise; + /** Called after the AI generates a response; can transform it */ + onAfterResponse?(ctx: SkillRuntimeContext, response: string): Promise; + /** Called before memory compaction */ + onMemoryFlush?(ctx: SkillRuntimeContext): Promise; + /** Called on a schedule (tickInterval ms) while active */ + onTick?(ctx: SkillRuntimeContext): Promise; + /** Called when connection status changes */ + onConnectionChange?(ctx: SkillRuntimeContext, status: string): Promise; +} + +// --------------------------------------------------------------------------- +// Tool Definitions +// --------------------------------------------------------------------------- + +/** Unified tool definition used across all systems */ +export interface SkillToolDefinition { + /** Standard MCP tool definition */ + tool: MCPTool; + /** Execution function */ + handler: SkillToolHandler; + /** Rate limit classification */ + tier?: ToolTier; + /** Whether the tool is safe for ask/plan modes */ + readOnly?: boolean; +} + +/** Tool execution handler */ +export type SkillToolHandler = ( + args: Record, + ctx: SkillToolContext, +) => Promise; + +/** Context passed to tool handlers */ +export interface SkillToolContext { + /** The owning skill's manifest */ + skillId: string; + /** Read the skill's Zustand state */ + getState(): S; + /** Write to the skill's Zustand state */ + setState(partial: Partial): void; + /** Log a message */ + log(message: string): void; +} + +// --------------------------------------------------------------------------- +// State Definition +// --------------------------------------------------------------------------- + +/** Skill state definition for Zustand */ +export interface SkillStateDefinition { + /** Initial state value */ + initialState: S; + /** Persistence configuration */ + persist?: { + /** Storage key name */ + name: string; + /** Fields to persist (default: all) */ + whitelist?: (keyof S & string)[]; + /** Fields to reset on rehydrate */ + volatileKeys?: (keyof S & string)[]; + }; +} + +// --------------------------------------------------------------------------- +// Entity Extension +// --------------------------------------------------------------------------- + +/** Entity type registration from a skill */ +export interface EntityTypeRegistration { + /** The entity type name */ + type: string; + /** Human-readable label */ + label: string; + /** Description of this entity type */ + description?: string; + /** The skill that registered this type */ + skillId: string; +} + +/** Relation type registration from a skill */ +export interface RelationTypeRegistration { + /** The relation type name */ + type: string; + /** Human-readable label */ + label: string; + /** Description of this relation */ + description?: string; + /** The skill that registered this type */ + skillId: string; +} + +/** Entity builder converts raw data into Entity records */ +export interface EntityBuilder { + /** Source system (e.g., "telegram", "onchain") */ + source: EntitySource | string; + /** Entity type this builder creates */ + entityType: EntityType | string; + /** Transform raw data into entity fields */ + build(rawData: unknown): { + sourceId: string; + title: string; + summary?: string; + metadata?: Record; + } | null; +} + +/** Entity extension definition for a skill */ +export interface SkillEntityDefinition { + /** New entity types to register */ + entityTypes?: EntityTypeRegistration[]; + /** New relation types to register */ + relationTypes?: RelationTypeRegistration[]; + /** Entity builders for converting raw data */ + builders?: EntityBuilder[]; +} + +// --------------------------------------------------------------------------- +// Intelligence Rules +// --------------------------------------------------------------------------- + +/** Trigger conditions for intelligence rules */ +export interface IntelligenceTrigger { + /** Event type to match (e.g., "entity_created", "message_received") */ + eventType: string; + /** Optional filter on event data */ + filter?: { + /** Match on entity type */ + entityType?: string; + /** Match on source */ + source?: string; + /** Custom predicate */ + predicate?: (data: unknown) => boolean; + }; +} + +/** Actions that intelligence rules can perform */ +export interface IntelligenceAction { + /** Action type */ + type: "create_entity" | "create_relation" | "tag_entity" | "custom"; + /** Parameters for the action */ + params?: Record; + /** Custom handler for "custom" action type */ + handler?: (data: unknown, ctx: IntelligenceActionContext) => Promise; +} + +/** Context passed to intelligence action handlers */ +export interface IntelligenceActionContext { + /** Entity manager for creating/updating entities */ + entities: EntityManager; + /** Log a message */ + log(message: string): void; +} + +/** An intelligence rule definition */ +export interface IntelligenceRule { + /** Unique rule ID */ + id: string; + /** Human-readable description */ + description: string; + /** What triggers this rule */ + trigger: IntelligenceTrigger; + /** What happens when triggered */ + action: IntelligenceAction; + /** Minimum ms between firings (default: no cooldown) */ + cooldownMs?: number; + /** Whether the rule is enabled (default: true) */ + enabled?: boolean; +} + +/** Intelligence definition for a skill */ +export interface SkillIntelligenceDefinition { + rules: IntelligenceRule[]; +} + +// --------------------------------------------------------------------------- +// Skill Definition (Complete) +// --------------------------------------------------------------------------- + +/** The complete skill definition — everything a skill provides */ +export interface SkillDefinition { + /** Skill manifest (metadata) */ + manifest: SkillManifest; + /** SKILL.md content (prompt-only skills) */ + promptContent?: string; + /** Lifecycle hooks */ + hooks?: SkillHooks; + /** Tool definitions */ + tools?: SkillToolDefinition[]; + /** Zustand state definition */ + state?: SkillStateDefinition; + /** Entity type extensions */ + entities?: SkillEntityDefinition; + /** Intelligence rules */ + intelligence?: SkillIntelligenceDefinition; + /** Tick interval in ms for onTick hook */ + tickInterval?: number; + /** Context prompt injected when skill tools are active */ + contextPrompt?: string; + /** Public methods exposed for inter-skill communication */ + publicMethods?: Record Promise>; +} + +// --------------------------------------------------------------------------- +// Runtime Context +// --------------------------------------------------------------------------- + +/** Public API of a skill for inter-skill communication */ +export interface SkillPublicAPI { + manifest: SkillManifest; + publicMethods: Record Promise>; +} + +/** Reference to the unified tool registry interface */ +export interface UnifiedToolRegistry { + registerTools(skillId: string, tools: SkillToolDefinition[]): void; + unregisterTools(skillId: string): void; + listTools(): MCPTool[]; + executeTool(name: string, args: Record): Promise; + getToolOwner(toolName: string): string | undefined; + getToolTier(toolName: string): ToolTier; + isReadOnly(toolName: string): boolean; +} + +/** Enhanced runtime context passed to skill hooks */ +export interface SkillRuntimeContext { + /** The skill's manifest */ + manifest: SkillManifest; + /** Memory manager for reading/writing memory files */ + memory: MemoryManager; + /** Session manager for current session */ + session: SessionManager; + /** Unified tool registry */ + toolRegistry: UnifiedToolRegistry; + /** Entity manager for querying the platform graph */ + entities: EntityManager; + /** Skill's own storage directory path */ + dataDir: string; + /** Read a file from the skill's data directory */ + readData(filename: string): Promise; + /** Write a file to the skill's data directory */ + writeData(filename: string, content: string): Promise; + /** Log a message */ + log(message: string): void; + /** Backend HTTP client */ + apiClient: ApiClient; + /** Read the skill's Zustand store */ + getState(): S; + /** Write to the skill's Zustand store */ + setState(partial: Partial): void; + /** Access another skill's public API */ + getSkill(skillId: string): SkillPublicAPI | undefined; + /** Emit an event to trigger intelligence rules */ + emitEvent(eventName: string, data: unknown): void; +} + +// --------------------------------------------------------------------------- +// Managed Skill (Internal) +// --------------------------------------------------------------------------- + +/** Internal state tracked per registered skill */ +export interface ManagedSkill { + definition: SkillDefinition; + status: SkillStatus; + error?: string; + context?: SkillRuntimeContext; + tickTimer?: ReturnType; +} diff --git a/src/services/api/__tests__/billingApi.test.ts b/src/services/api/__tests__/billingApi.test.ts new file mode 100644 index 000000000..3426c45ac --- /dev/null +++ b/src/services/api/__tests__/billingApi.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { billingApi } from "../billingApi"; + +// Mock the apiClient module +const mockGet = vi.fn(); +const mockPost = vi.fn(); + +vi.mock("../../apiClient", () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})); + +describe("billingApi", () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + }); + + describe("getCurrentPlan", () => { + it("should call GET /payments/stripe/currentPlan", async () => { + const planData = { + plan: "BASIC", + hasActiveSubscription: true, + planExpiry: "2026-12-31T00:00:00.000Z", + subscription: { + id: "sub_123", + status: "active", + currentPeriodEnd: "2026-12-31T00:00:00.000Z", + }, + }; + mockGet.mockResolvedValue({ success: true, data: planData }); + + const result = await billingApi.getCurrentPlan(); + + expect(mockGet).toHaveBeenCalledWith("/payments/stripe/currentPlan"); + expect(result).toEqual(planData); + }); + + it("should return FREE plan data for free users", async () => { + const planData = { + plan: "FREE", + hasActiveSubscription: false, + planExpiry: null, + subscription: null, + }; + mockGet.mockResolvedValue({ success: true, data: planData }); + + const result = await billingApi.getCurrentPlan(); + + expect(result.plan).toBe("FREE"); + expect(result.hasActiveSubscription).toBe(false); + expect(result.subscription).toBeNull(); + }); + + it("should propagate errors from apiClient", async () => { + mockGet.mockRejectedValue({ success: false, error: "Unauthorized" }); + + await expect(billingApi.getCurrentPlan()).rejects.toEqual({ + success: false, + error: "Unauthorized", + }); + }); + }); + + describe("purchasePlan", () => { + it("should call POST /payments/stripe/purchasePlan with plan ID", async () => { + const checkoutData = { + checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_123", + sessionId: "cs_test_123", + }; + mockPost.mockResolvedValue({ success: true, data: checkoutData }); + + const result = await billingApi.purchasePlan("BASIC_MONTHLY"); + + expect(mockPost).toHaveBeenCalledWith( + "/payments/stripe/purchasePlan", + { plan: "BASIC_MONTHLY" }, + ); + expect(result).toEqual(checkoutData); + }); + + it("should pass yearly plan IDs correctly", async () => { + mockPost.mockResolvedValue({ + success: true, + data: { checkoutUrl: "https://stripe.com/...", sessionId: "cs_456" }, + }); + + await billingApi.purchasePlan("PRO_YEARLY"); + + expect(mockPost).toHaveBeenCalledWith( + "/payments/stripe/purchasePlan", + { plan: "PRO_YEARLY" }, + ); + }); + + it("should return null checkoutUrl when session creation has no URL", async () => { + mockPost.mockResolvedValue({ + success: true, + data: { checkoutUrl: null, sessionId: "cs_789" }, + }); + + const result = await billingApi.purchasePlan("BASIC_MONTHLY"); + + expect(result.checkoutUrl).toBeNull(); + expect(result.sessionId).toBe("cs_789"); + }); + + it("should propagate errors from apiClient", async () => { + mockPost.mockRejectedValue({ + success: false, + error: "Invalid plan", + }); + + await expect(billingApi.purchasePlan("BASIC_MONTHLY")).rejects.toEqual({ + success: false, + error: "Invalid plan", + }); + }); + }); + + describe("createPortalSession", () => { + it("should call POST /payments/stripe/portal with no body", async () => { + const portalData = { + portalUrl: "https://billing.stripe.com/p/session/test_123", + }; + mockPost.mockResolvedValue({ success: true, data: portalData }); + + const result = await billingApi.createPortalSession(); + + expect(mockPost).toHaveBeenCalledWith("/payments/stripe/portal"); + expect(result).toEqual(portalData); + }); + + it("should return the portal URL string", async () => { + mockPost.mockResolvedValue({ + success: true, + data: { portalUrl: "https://billing.stripe.com/session/abc" }, + }); + + const result = await billingApi.createPortalSession(); + + expect(result.portalUrl).toBe( + "https://billing.stripe.com/session/abc", + ); + }); + + it("should propagate errors from apiClient", async () => { + mockPost.mockRejectedValue({ + success: false, + error: "Unable to resolve Stripe customer", + }); + + await expect(billingApi.createPortalSession()).rejects.toEqual({ + success: false, + error: "Unable to resolve Stripe customer", + }); + }); + }); + + describe("createCoinbaseCharge", () => { + it("should call POST /payments/coinbase/charge with plan and interval", async () => { + const chargeData = { + gatewayTransactionId: "charge_abc", + hostedUrl: "https://commerce.coinbase.com/charges/abc", + status: "created", + expiresAt: "2026-01-31T12:15:00.000Z", + }; + mockPost.mockResolvedValue({ success: true, data: chargeData }); + + const result = await billingApi.createCoinbaseCharge("BASIC", "annual"); + + expect(mockPost).toHaveBeenCalledWith("/payments/coinbase/charge", { + plan: "BASIC", + interval: "annual", + }); + expect(result).toEqual(chargeData); + }); + + it("should default interval to annual", async () => { + mockPost.mockResolvedValue({ + success: true, + data: { + gatewayTransactionId: "charge_xyz", + hostedUrl: "https://commerce.coinbase.com/charges/xyz", + status: "created", + expiresAt: "2026-01-31T12:15:00.000Z", + }, + }); + + await billingApi.createCoinbaseCharge("PRO"); + + expect(mockPost).toHaveBeenCalledWith("/payments/coinbase/charge", { + plan: "PRO", + interval: "annual", + }); + }); + + it("should return hosted URL for payment redirect", async () => { + const expectedUrl = "https://commerce.coinbase.com/charges/test"; + mockPost.mockResolvedValue({ + success: true, + data: { + gatewayTransactionId: "charge_t", + hostedUrl: expectedUrl, + status: "created", + expiresAt: "2026-01-31T12:15:00.000Z", + }, + }); + + const result = await billingApi.createCoinbaseCharge("BASIC", "annual"); + + expect(result.hostedUrl).toBe(expectedUrl); + }); + + it("should propagate errors from apiClient", async () => { + mockPost.mockRejectedValue({ + success: false, + error: "Crypto payments are only available for annual plans", + }); + + await expect( + billingApi.createCoinbaseCharge("BASIC", "annual"), + ).rejects.toEqual({ + success: false, + error: "Crypto payments are only available for annual plans", + }); + }); + }); +}); diff --git a/src/services/api/billingApi.ts b/src/services/api/billingApi.ts new file mode 100644 index 000000000..98a38b992 --- /dev/null +++ b/src/services/api/billingApi.ts @@ -0,0 +1,65 @@ +import { apiClient } from "../apiClient"; +import type { + ApiResponse, + CurrentPlanData, + PurchasePlanData, + PortalSessionData, + CoinbaseChargeData, + PlanIdentifier, + PlanTier, +} from "../../types/api"; + +/** + * Billing API endpoints + */ +export const billingApi = { + /** + * Get the current user's subscription plan + * GET /payments/stripe/currentPlan + */ + getCurrentPlan: async (): Promise => { + const response = + await apiClient.get>( + "/payments/stripe/currentPlan", + ); + return response.data; + }, + + /** + * Create a Stripe Checkout session for a plan purchase + * POST /payments/stripe/purchasePlan + */ + purchasePlan: async (plan: PlanIdentifier): Promise => { + const response = await apiClient.post>( + "/payments/stripe/purchasePlan", + { plan }, + ); + return response.data; + }, + + /** + * Create a Stripe Customer Portal session + * POST /payments/stripe/portal + */ + createPortalSession: async (): Promise => { + const response = await apiClient.post>( + "/payments/stripe/portal", + ); + return response.data; + }, + + /** + * Create a Coinbase Commerce charge (annual-only) + * POST /payments/coinbase/charge + */ + createCoinbaseCharge: async ( + plan: PlanTier, + interval: "annual" = "annual", + ): Promise => { + const response = await apiClient.post>( + "/payments/coinbase/charge", + { plan, interval }, + ); + return response.data; + }, +}; diff --git a/src/types/api.ts b/src/types/api.ts index 39532bc8c..1e4cb60ae 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -66,5 +66,41 @@ export interface User { waitlist?: string; } +// Billing types +export type PlanTier = "FREE" | "BASIC" | "PRO"; + +export type PlanIdentifier = + | "BASIC_MONTHLY" + | "BASIC_YEARLY" + | "PRO_MONTHLY" + | "PRO_YEARLY"; + +export interface CurrentPlanData { + plan: PlanTier; + hasActiveSubscription: boolean; + planExpiry: string | null; + subscription: { + id: string; + status: string; + currentPeriodEnd: string; + } | null; +} + +export interface PurchasePlanData { + checkoutUrl: string | null; + sessionId: string; +} + +export interface PortalSessionData { + portalUrl: string; +} + +export interface CoinbaseChargeData { + gatewayTransactionId: string; + hostedUrl: string; + status: string; + expiresAt: string; +} + // API Endpoints export type GetMeResponse = ApiResponse;