mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
Feat/payment (#10)
* Add submodule for skills and initialize skills repository - Created a new .gitmodules file to include the skills submodule. - Initialized the skills submodule with a commit reference, linking to the skills repository on GitHub. This update integrates the skills component into the project, enhancing the AI system's capabilities. * Implement subscription billing UI with Stripe and Coinbase integration Replace the placeholder BillingPanel with a working subscription billing interface. Users can view their current plan and token usage, upgrade via Stripe (card) or Coinbase (crypto, annual-only), and manage subscriptions through Stripe's Customer Portal. Adds billing API service and types. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add unified skill system with core type definitions and management features - Introduced a comprehensive skill system that includes lifecycle management, tool definitions, state management, entity extensions, and intelligence rules. - Implemented a skill state manager using Zustand for managing skill-specific state. - Created an entity extension registry for registering new entity and relation types. - Developed an intelligence engine for evaluating rules and executing actions based on events. - Established a unified tool registry to streamline tool management across various integrations. This update enhances the AI system's capabilities by providing a robust framework for skill management and interaction. * Add billing helpers and refactor BillingPanel for improved plan management - Introduced a new `billingHelpers.ts` file containing plan metadata, utility functions for plan management, and pricing calculations. - Refactored `BillingPanel` to utilize the new billing helpers, streamlining the display of plan information and pricing. - Added unit tests for billing helpers to ensure functionality and correctness of plan-related operations. This update enhances the billing system's structure and maintainability, providing a clearer separation of concerns for plan management. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d668a3a66a
commit
378a803dd7
@@ -0,0 +1,3 @@
|
||||
[submodule "skills"]
|
||||
path = skills
|
||||
url = https://github.com/vezuresdotxyz/alphahuman-skills
|
||||
@@ -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
|
||||
Submodule
+1
Submodule skills added at 0701d15178
@@ -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<PlanTier | null>(null);
|
||||
const [currentPlanData, setCurrentPlanData] =
|
||||
useState<CurrentPlanData | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollStartRef = useRef<number>(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 (
|
||||
<div className="overflow-hidden h-full flex flex-col">
|
||||
<SettingsHeader
|
||||
@@ -13,11 +142,266 @@ const BillingPanel = () => {
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-stone-700/50 rounded-full flex items-center justify-center">
|
||||
<div className="p-4 space-y-5">
|
||||
{/* ── Current plan banner ──────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-stone-800/60 border border-stone-700/50 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{isLoading
|
||||
? "Loading..."
|
||||
: (currentPlanData?.plan ?? currentTier)}{" "}
|
||||
Plan
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
hasActive
|
||||
? "bg-sage-500/20 text-sage-400 border border-sage-500/30"
|
||||
: "bg-stone-600/30 text-stone-400 border border-stone-600/40"
|
||||
}`}
|
||||
>
|
||||
{hasActive ? "Active" : "Free"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasActive && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 font-medium transition-colors"
|
||||
>
|
||||
Manage Subscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Renewal date */}
|
||||
{hasActive && planExpiry && (
|
||||
<p className="text-xs text-stone-400 mb-3">
|
||||
Renews{" "}
|
||||
{new Date(planExpiry).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Token usage */}
|
||||
{usage && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-stone-400 mb-1.5">
|
||||
<span>Daily token usage</span>
|
||||
<span>
|
||||
{usage.remainingTokens.toLocaleString()} /{" "}
|
||||
{usage.dailyTokenLimit.toLocaleString()} remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-primary-500"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(usage.remainingTokens / usage.dailyTokenLimit) * 100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Interval toggle ──────────────────────────────────── */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== "crypto") setBillingInterval("monthly");
|
||||
}}
|
||||
disabled={paymentMethod === "crypto"}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === "monthly"
|
||||
? "bg-primary-500/20 text-primary-400 border border-primary-500/30"
|
||||
: "text-stone-400 hover:text-stone-300"
|
||||
} ${paymentMethod === "crypto" ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval("annual")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
billingInterval === "annual"
|
||||
? "bg-primary-500/20 text-primary-400 border border-primary-500/30"
|
||||
: "text-stone-400 hover:text-stone-300"
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Plan tier cards ───────────────────────────────────── */}
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`rounded-2xl border p-4 transition-all ${
|
||||
isCurrent
|
||||
? "border-primary-500/40 bg-primary-500/5"
|
||||
: "border-stone-700/50 bg-stone-800/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
{plan.name}
|
||||
</h4>
|
||||
{isCurrent && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-white">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
</span>
|
||||
{plan.tier !== "FREE" && (
|
||||
<span className="text-xs text-stone-400">/mo</span>
|
||||
)}
|
||||
{plan.tier !== "FREE" &&
|
||||
billingInterval === "annual" && (
|
||||
<span className="text-xs text-stone-500 ml-1">
|
||||
(billed ${plan.annualPrice}/yr)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{isUpgrade && (
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
isPurchasing
|
||||
? "bg-stone-700/40 text-stone-500 cursor-not-allowed"
|
||||
: "bg-primary-500 hover:bg-primary-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{isThisPurchasing ? "Waiting..." : "Upgrade"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-1.5">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f.text}
|
||||
className="flex items-center gap-2 text-xs text-stone-300"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-sage-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{f.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Purchasing overlay message ────────────────────────── */}
|
||||
{isPurchasing && (
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-amber-400 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-amber-300">
|
||||
Waiting for payment confirmation... Complete checkout in the
|
||||
browser window that opened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pay with crypto toggle ────────────────────────────── */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-800/40 border border-stone-700/40 p-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-white">
|
||||
Pay with Crypto
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
Annual plans only via Coinbase Commerce
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPaymentMethod((m) => (m === "card" ? "crypto" : "card"))
|
||||
}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
paymentMethod === "crypto"
|
||||
? "bg-primary-500"
|
||||
: "bg-stone-600"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={paymentMethod === "crypto"}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
paymentMethod === "crypto"
|
||||
? "translate-x-5"
|
||||
: "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Info notice ───────────────────────────────────────── */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-8 h-8 text-stone-400"
|
||||
className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -26,20 +410,13 @@ const BillingPanel = () => {
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Billing & Subscription
|
||||
</h3>
|
||||
<p className="text-stone-400 text-sm max-w-sm mx-auto">
|
||||
Manage your subscription, payment methods, and billing history.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<span className="px-4 py-2 text-sm font-medium rounded-full border bg-stone-700/30 text-stone-300 border-stone-600/50">
|
||||
Coming Soon
|
||||
</span>
|
||||
<p className="text-[11px] text-blue-200">
|
||||
Payments processed securely through Stripe. Crypto payments
|
||||
available for annual plans via Coinbase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
onUnload?(ctx: unknown): Promise<void>;
|
||||
onSessionStart?(ctx: unknown, sessionId: string): Promise<void>;
|
||||
onSessionEnd?(ctx: unknown, sessionId: string): Promise<void>;
|
||||
onBeforeMessage?(ctx: unknown, message: string): Promise<string | void>;
|
||||
onAfterResponse?(ctx: unknown, response: string): Promise<string | void>;
|
||||
onMemoryFlush?(ctx: unknown): Promise<void>;
|
||||
onTick?(ctx: unknown): Promise<void>;
|
||||
};
|
||||
tools?: Array<{
|
||||
definition: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
execute(args: Record<string, unknown>): 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<SkillDefinition | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, EntityTypeRegistration>();
|
||||
/** Skill-registered relation types, keyed by type name */
|
||||
private relationTypes = new Map<string, RelationTypeRegistration>();
|
||||
/** Entity builders, keyed by skill ID */
|
||||
private builders = new Map<string, EntityBuilder[]>();
|
||||
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, RegisteredRule>();
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ToolsCallParams {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StateSetParams {
|
||||
partial: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EntitiesSearchParams {
|
||||
query: string;
|
||||
types?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EntitiesAddRelationParams {
|
||||
fromEntityId: string;
|
||||
toEntityId: string;
|
||||
relationType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
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;
|
||||
@@ -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<string, StoreApi<Record<string, unknown>>>();
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
|
||||
if (definition.persist) {
|
||||
const { name, whitelist, volatileKeys } = definition.persist;
|
||||
|
||||
const store = createStore(
|
||||
persist<Record<string, unknown>>(
|
||||
() => ({ ...initial }),
|
||||
{
|
||||
name: `skill-${name}`,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => {
|
||||
if (whitelist && whitelist.length > 0) {
|
||||
const partial: Record<string, unknown> = {};
|
||||
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<Record<string, unknown>>(() => ({ ...initial }));
|
||||
this.stores.set(skillId, store);
|
||||
}
|
||||
|
||||
log("Created store for skill %s", skillId);
|
||||
}
|
||||
|
||||
/** Get a skill's current state */
|
||||
getState<S>(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<S>(skillId: string, partial: Partial<S>): void {
|
||||
const store = this.stores.get(skillId);
|
||||
if (!store) return;
|
||||
store.setState(partial as Partial<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, RegisteredTool>();
|
||||
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<string, unknown>,
|
||||
): Promise<MCPToolResult | undefined> {
|
||||
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: <S>() =>
|
||||
(this.stateManager.getState<S>(entry.skillId) ?? {}) as S,
|
||||
setState: <S>(partial: Partial<S>) =>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Enhanced lifecycle hooks that a skill can implement */
|
||||
export interface SkillHooks {
|
||||
/** Called when skill is loaded */
|
||||
onLoad?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called when skill is unloaded */
|
||||
onUnload?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called when skill becomes active */
|
||||
onActivate?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called when skill is being deactivated */
|
||||
onDeactivate?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called when a new AI session starts */
|
||||
onSessionStart?(ctx: SkillRuntimeContext, sessionId: string): Promise<void>;
|
||||
/** Called when an AI session ends */
|
||||
onSessionEnd?(ctx: SkillRuntimeContext, sessionId: string): Promise<void>;
|
||||
/** Called before the AI processes a user message; can transform it */
|
||||
onBeforeMessage?(ctx: SkillRuntimeContext, message: string): Promise<string | void>;
|
||||
/** Called after the AI generates a response; can transform it */
|
||||
onAfterResponse?(ctx: SkillRuntimeContext, response: string): Promise<string | void>;
|
||||
/** Called before memory compaction */
|
||||
onMemoryFlush?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called on a schedule (tickInterval ms) while active */
|
||||
onTick?(ctx: SkillRuntimeContext): Promise<void>;
|
||||
/** Called when connection status changes */
|
||||
onConnectionChange?(ctx: SkillRuntimeContext, status: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, unknown>,
|
||||
ctx: SkillToolContext,
|
||||
) => Promise<MCPToolResult>;
|
||||
|
||||
/** Context passed to tool handlers */
|
||||
export interface SkillToolContext {
|
||||
/** The owning skill's manifest */
|
||||
skillId: string;
|
||||
/** Read the skill's Zustand state */
|
||||
getState<S = unknown>(): S;
|
||||
/** Write to the skill's Zustand state */
|
||||
setState<S = unknown>(partial: Partial<S>): void;
|
||||
/** Log a message */
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State Definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Skill state definition for Zustand */
|
||||
export interface SkillStateDefinition<S = unknown> {
|
||||
/** 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<string, unknown>;
|
||||
} | 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<string, unknown>;
|
||||
/** Custom handler for "custom" action type */
|
||||
handler?: (data: unknown, ctx: IntelligenceActionContext) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Public API of a skill for inter-skill communication */
|
||||
export interface SkillPublicAPI {
|
||||
manifest: SkillManifest;
|
||||
publicMethods: Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
}
|
||||
|
||||
/** 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<string, unknown>): Promise<MCPToolResult | undefined>;
|
||||
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<string>;
|
||||
/** Write a file to the skill's data directory */
|
||||
writeData(filename: string, content: string): Promise<void>;
|
||||
/** Log a message */
|
||||
log(message: string): void;
|
||||
/** Backend HTTP client */
|
||||
apiClient: ApiClient;
|
||||
/** Read the skill's Zustand store */
|
||||
getState<S = unknown>(): S;
|
||||
/** Write to the skill's Zustand store */
|
||||
setState<S = unknown>(partial: Partial<S>): 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<typeof setInterval>;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<CurrentPlanData> => {
|
||||
const response =
|
||||
await apiClient.get<ApiResponse<CurrentPlanData>>(
|
||||
"/payments/stripe/currentPlan",
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout session for a plan purchase
|
||||
* POST /payments/stripe/purchasePlan
|
||||
*/
|
||||
purchasePlan: async (plan: PlanIdentifier): Promise<PurchasePlanData> => {
|
||||
const response = await apiClient.post<ApiResponse<PurchasePlanData>>(
|
||||
"/payments/stripe/purchasePlan",
|
||||
{ plan },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal session
|
||||
* POST /payments/stripe/portal
|
||||
*/
|
||||
createPortalSession: async (): Promise<PortalSessionData> => {
|
||||
const response = await apiClient.post<ApiResponse<PortalSessionData>>(
|
||||
"/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<CoinbaseChargeData> => {
|
||||
const response = await apiClient.post<ApiResponse<CoinbaseChargeData>>(
|
||||
"/payments/coinbase/charge",
|
||||
{ plan, interval },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -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<User>;
|
||||
|
||||
Reference in New Issue
Block a user