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:
Steven Enamakel
2026-01-31 21:41:51 +05:30
committed by GitHub
co-authored by Claude Opus 4.5
parent d668a3a66a
commit 378a803dd7
16 changed files with 2472 additions and 16 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "skills"]
path = skills
url = https://github.com/vezuresdotxyz/alphahuman-skills
+6
View File
@@ -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
+393 -16
View File
@@ -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);
}
+176
View File
@@ -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;
}
+147
View File
@@ -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();
}
}
+196
View File
@@ -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();
}
}
+271
View File
@@ -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;
+121
View File
@@ -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);
}
}
}
+156
View File
@@ -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;
}
}
+357
View File
@@ -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",
});
});
});
});
+65
View File
@@ -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;
},
};
+36
View File
@@ -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>;