feat: implement multi-tool package architecture with manual tool registry

BREAKING CHANGE: Complete refactoring from single-tool to multi-tool package support

Database Schema:
- Split Tool model into Package (1) and Tool (many) with one-to-many relationship
- Package stores npm metadata and package-level tpmjs fields (category, env, frameworks, tier)
- Tool stores individual tool exports with tool-level metadata (exportName, description, parameters, returns, aiAgent)
- Unique constraint on (packageId, exportName) to prevent duplicate tools
- Cascade deletes when packages are removed

Type System:
- Updated tpmjs field schema to support tools array
- Each tool has exportName, description, parameters, returns, aiAgent
- Package-level fields: category, env, frameworks shared across all tools
- Backward compatible with legacy single-tool format (auto-migrates to exportName: "default")

API Updates:
- Updated all /api/tools routes to query Tool model with Package relations
- Updated /api/tools/[slug] to accept package/export path segments
- Updated tool-executor-agent to use actual exportName instead of hardcoded "default"
- Updated metrics sync to calculate quality scores per Tool

Frontend Updates:
- Updated tool search page to display exportName as primary heading
- Updated tool detail pages to show package name as secondary info
- Removed tag-based filtering (tags moved to package level)

Manual Tool Registry:
- Added manual-tools.ts with 23 curated tools from major providers
- Created sync-manual-tools.ts script to sync manual tools to database
- Added MANUAL_TOOLS.md documentation for manual tool system
- Added GitHub workflow for automated daily sync
- Includes tools from: Vercel, Exa, Firecrawl, AWS Bedrock, Perplexity, Tavily, Superagent, Valyu

Playground Updates:
- Updated tool loader to load multiple tools per package
- Added sanitizeToolName for OpenAI API compatibility

Sync System Updates:
- Updated changes feed sync to handle multi-tool packages
- Updated keyword sync to upsert multiple tools per package
- Added orphaned tool deletion when tools removed from package.json

Migration Strategy:
- Database uses same Neon instance for dev and prod
- Schema updated via prisma db push (no migration files yet)
- All data repopulates from npm via sync system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 06:39:58 +10:00
parent f59c2c5123
commit 141a64d888
31 changed files with 3783 additions and 786 deletions

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -18,25 +18,14 @@ export const dynamic = 'force-dynamic';
export async function GET() {
try {
// Run all aggregations in parallel
const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([
const [totalTools, officialTools, recentCount, packages] = await Promise.all([
// Total tools count
prisma.tool.count(),
// Official tools count
// Official tools count (isOfficial is at package level)
prisma.tool.count({
where: { isOfficial: true },
}),
// Group by category
prisma.tool.groupBy({
by: ['category'],
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
where: {
package: { isOfficial: true }
},
}),
@ -49,21 +38,31 @@ export async function GET() {
},
}),
// Sum of all downloads
prisma.tool.aggregate({
_sum: {
// Get all packages with their tool counts and download stats
prisma.package.findMany({
select: {
category: true,
npmDownloadsLastMonth: true,
_count: {
select: { tools: true },
},
},
}),
]);
// Format category stats
const categories = categoryStats.reduce<Record<string, number>>((acc, stat) => {
if (stat.category) {
acc[stat.category] = stat._count.id;
// Calculate stats from packages
const categories: Record<string, number> = {};
let totalDownloads = 0;
for (const pkg of packages) {
// Count tools by category
if (pkg.category) {
categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools;
}
return acc;
}, {});
// Sum downloads
totalDownloads += pkg.npmDownloadsLastMonth || 0;
}
return NextResponse.json({
success: true,
@ -72,7 +71,7 @@ export async function GET() {
officialTools,
categories,
recentTools: recentCount,
totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0,
totalDownloads,
},
});
} catch (error) {

View file

@ -66,74 +66,119 @@ export async function POST(request: NextRequest) {
continue;
}
// Validate tpmjs field
// Validate tpmjs field (supports both new multi-tool and legacy formats)
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.data) {
if (!validation.valid || !validation.packageData || !validation.tools) {
skipped++;
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
const tpmjsData = validation.data as {
category: string;
description: string;
example: string;
parameters?: unknown;
returns?: unknown;
authentication?: unknown;
pricing?: unknown;
frameworks?: string[];
links?: unknown;
tags?: string[];
status?: string;
aiAgent?: unknown;
};
// Prepare data for upsert
const toolData = {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: tpmjsData.category,
description: tpmjsData.description,
example: tpmjsData.example,
parameters: tpmjsData.parameters ?? undefined,
returns: tpmjsData.returns ?? undefined,
authentication: tpmjsData.authentication ?? undefined,
pricing: tpmjsData.pricing ?? undefined,
frameworks: tpmjsData.frameworks || [],
links: tpmjsData.links ?? undefined,
tags: tpmjsData.tags || [],
status: tpmjsData.status ?? undefined,
aiAgent: tpmjsData.aiAgent ?? undefined,
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
tier: validation.tier || 'minimal',
};
// Upsert tool to database
await prisma.tool.upsert({
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: {
npmPackageName: pkg.name,
...toolData,
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
discoveryMethod: 'changes-feed',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
qualityScore: null, // Will be calculated by metrics sync
},
update: toolData,
update: {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
},
});
// Get existing tools for this package
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Upsert each tool in the tools array
for (const toolDef of validation.tools) {
await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
},
update: {
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
},
});
}
// Delete orphaned tools (tools removed from package.json)
const orphanedTools = existingTools.filter(
(existingTool) =>
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
);
if (orphanedTools.length > 0) {
await prisma.tool.deleteMany({
where: {
id: { in: orphanedTools.map((t) => t.id) },
},
});
console.log(
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
);
}
processed++;
} catch (error) {
errors++;

View file

@ -75,9 +75,9 @@ export async function POST(request: NextRequest) {
continue;
}
// Validate tpmjs field
// Validate tpmjs field (supports both new multi-tool and legacy formats)
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.data) {
if (!validation.valid || !validation.packageData || !validation.tools) {
skipped++;
skippedPackages.push({
name: pkg.name,
@ -87,67 +87,112 @@ export async function POST(request: NextRequest) {
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
const tpmjsData = validation.data as {
category: string;
description: string;
example: string;
parameters?: unknown;
returns?: unknown;
authentication?: unknown;
pricing?: unknown;
frameworks?: string[];
links?: unknown;
tags?: string[];
status?: string;
aiAgent?: unknown;
};
// Prepare data for upsert
const toolData = {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: tpmjsData.category,
description: tpmjsData.description,
example: tpmjsData.example,
parameters: tpmjsData.parameters ?? undefined,
returns: tpmjsData.returns ?? undefined,
authentication: tpmjsData.authentication ?? undefined,
pricing: tpmjsData.pricing ?? undefined,
frameworks: tpmjsData.frameworks || [],
links: tpmjsData.links ?? undefined,
tags: tpmjsData.tags || [],
status: tpmjsData.status ?? undefined,
aiAgent: tpmjsData.aiAgent ?? undefined,
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
tier: validation.tier || 'minimal',
};
// Upsert tool to database
await prisma.tool.upsert({
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: {
npmPackageName: pkg.name,
...toolData,
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
discoveryMethod: 'keyword',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
qualityScore: null, // Will be calculated by metrics sync
},
update: toolData,
update: {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
},
});
// Get existing tools for this package
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Upsert each tool in the tools array
for (const toolDef of validation.tools) {
await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
},
update: {
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
},
});
}
// Delete orphaned tools (tools removed from package.json)
const orphanedTools = existingTools.filter(
(existingTool) =>
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
);
if (orphanedTools.length > 0) {
await prisma.tool.deleteMany({
where: {
id: { in: orphanedTools.map((t) => t.id) },
},
});
console.log(
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
);
}
processed++;
} catch (error) {
errors++;

View file

@ -9,7 +9,7 @@ export const maxDuration = 300; // 5 minutes max for cron jobs
/**
* POST /api/sync/metrics
* Update download stats and quality scores for all tools
* Update download stats and quality scores for all packages and tools
*
* This endpoint is called by Vercel Cron (every hour)
* Requires Authorization: Bearer <CRON_SECRET>
@ -31,43 +31,51 @@ export async function POST(request: NextRequest) {
const errorMessages: string[] = [];
try {
// Get all tools from database
const tools = await prisma.tool.findMany({
select: {
id: true,
npmPackageName: true,
tier: true,
npmDownloadsLastMonth: true,
githubStars: true,
// Get all packages with their tools from database
const packages = await prisma.package.findMany({
include: {
tools: true,
},
});
// Process each tool
for (const tool of tools) {
// Process each package
for (const pkg of packages) {
try {
// Fetch download stats from NPM
const downloads = await fetchDownloadStats(tool.npmPackageName);
// Fetch download stats from NPM (package-level metric)
const downloads = await fetchDownloadStats(pkg.npmPackageName);
// Calculate quality score (0.00 to 1.00)
const qualityScore = calculateQualityScore({
tier: tool.tier,
downloads,
githubStars: tool.githubStars || 0,
});
// Update tool metrics
await prisma.tool.update({
where: { id: tool.id },
// Update package metrics
await prisma.package.update({
where: { id: pkg.id },
data: {
npmDownloadsLastMonth: downloads,
qualityScore,
// githubStars would be updated here if we had GitHub API integration
},
});
// Calculate and update quality score for each tool in this package
for (const tool of pkg.tools) {
const qualityScore = calculateQualityScore({
tier: pkg.tier, // Tier is at package level
downloads, // Package downloads
githubStars: pkg.githubStars || 0, // Package stars
hasParameters: !!tool.parameters,
hasReturns: !!tool.returns,
hasAiAgent: !!tool.aiAgent,
});
await prisma.tool.update({
where: { id: tool.id },
data: {
qualityScore,
},
});
}
processed++;
} catch (error) {
errors++;
const errorMsg = `Failed to process ${tool.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(errorMsg);
}
@ -80,13 +88,15 @@ export async function POST(request: NextRequest) {
source: 'metrics',
checkpoint: {
lastRun: new Date().toISOString(),
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
update: {
checkpoint: {
lastRun: new Date().toISOString(),
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
});
@ -102,10 +112,11 @@ export async function POST(request: NextRequest) {
message:
errors > 0
? `Processed with errors: ${errorMessages.slice(0, 3).join('; ')}`
: `Successfully updated metrics for ${processed} tools`,
: `Successfully updated metrics for ${processed} packages`,
metadata: {
durationMs: Date.now() - startTime,
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
});
@ -116,7 +127,8 @@ export async function POST(request: NextRequest) {
processed,
skipped,
errors,
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
durationMs: Date.now() - startTime,
},
});
@ -152,25 +164,40 @@ export async function POST(request: NextRequest) {
/**
* Calculate quality score based on multiple factors
* Returns a value between 0.00 and 1.00
*
* Score components:
* - Tier (0.4 minimal, 0.6 rich)
* - Downloads (logarithmic, max 0.2)
* - GitHub stars (logarithmic, max 0.1)
* - Tool metadata richness (0.1 for each: parameters, returns, aiAgent)
*/
function calculateQualityScore(params: {
tier: string;
downloads: number;
githubStars: number;
hasParameters: boolean;
hasReturns: boolean;
hasAiAgent: boolean;
}): number {
const { tier, downloads, githubStars } = params;
const { tier, downloads, githubStars, hasParameters, hasReturns, hasAiAgent } = params;
// Base score from tier
const tierScore = tier === 'rich' ? 0.6 : 0.4;
// Downloads score (logarithmic scale, max 0.3)
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
// Downloads score (logarithmic scale, max 0.2)
const downloadsScore = Math.min(0.2, Math.log10(downloads + 1) / 15);
// GitHub stars score (logarithmic scale, max 0.1)
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
// Tool metadata richness score (max 0.1)
let richnessScore = 0;
if (hasParameters) richnessScore += 0.04;
if (hasReturns) richnessScore += 0.03;
if (hasAiAgent) richnessScore += 0.03;
// Total score (capped at 1.00)
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore);
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore + richnessScore);
// Round to 2 decimal places
return Math.round(totalScore * 100) / 100;

View file

@ -18,31 +18,83 @@ export async function GET(
try {
const { slug } = await params;
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
const packageName = slug.join('/');
// Slug can be:
// - ['@scope', 'package'] -> Get all tools for @scope/package
// - ['@scope', 'package', 'exportName'] -> Get specific tool @scope/package/exportName
// - ['package'] -> Get all tools for package
// - ['package', 'exportName'] -> Get specific tool package/exportName
// Find the tool by npmPackageName
const tool = await prisma.tool.findUnique({
where: {
npmPackageName: packageName,
},
});
let packageName: string;
let exportName: string | undefined;
if (!tool) {
return NextResponse.json(
{
success: false,
error: 'Tool not found',
},
{ status: 404 }
);
if (slug.length === 1) {
// Single slug - package name without scope
packageName = slug[0] || '';
} else if (slug.length === 2) {
// Could be: @scope/package OR package/exportName
if (slug[0]?.startsWith('@')) {
// @scope/package
packageName = slug.join('/');
} else {
// package + exportName
packageName = slug[0] || '';
exportName = slug[1];
}
} else {
// 3+ slugs: @scope/package/exportName
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
exportName = slug[slug.length - 1];
}
// Return the tool data
return NextResponse.json({
success: true,
data: tool,
});
if (exportName) {
// Find specific tool by package name and export name
const tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
include: { package: true },
});
if (!tool) {
return NextResponse.json(
{
success: false,
error: 'Tool not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: tool,
});
} else {
// Find all tools for the package
const pkg = await prisma.package.findUnique({
where: { npmPackageName: packageName },
include: { tools: true },
});
if (!pkg) {
return NextResponse.json(
{
success: false,
error: 'Package not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
package: pkg,
tools: pkg.tools,
},
});
}
} catch (error) {
console.error('Error fetching tool:', error);
return NextResponse.json(

View file

@ -18,15 +18,19 @@ interface ExecuteRequest {
}
/**
* POST /api/tools/[...slug]/execute
* POST /api/tools/execute/[...slug]
* Executes a tool with an AI agent and streams the response via SSE
*
* Slug format: [toolId] or [packageName, exportName]
* Examples:
* /api/tools/execute/clx123abc (by tool ID)
* /api/tools/execute/@tpmjs/hello/helloWorldTool (by package and export name)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
const packageName = decodeURIComponent(slug.join('/'));
try {
// Parse request body
@ -63,10 +67,29 @@ export async function POST(
);
}
// Fetch tool from database
const tool = await prisma.tool.findUnique({
where: { npmPackageName: packageName },
});
// Fetch tool from database with package relation
// Support both ID-based lookup and packageName/exportName lookup
let tool;
if (slug.length === 1) {
// Single slug - treat as tool ID
tool = await prisma.tool.findUnique({
where: { id: slug[0] || '' },
include: { package: true },
});
} else {
// Multiple slugs - treat as packageName/exportName
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
include: { package: true },
});
}
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });

View file

@ -10,10 +10,10 @@ export const maxDuration = 60;
* Search and list tools with filtering, sorting, and pagination
*
* Query params:
* - q: Search query (searches name, description, tags)
* - q: Search query (searches package name, tool description)
* - category: Filter by category
* - official: Filter by official status (true/false)
* - limit: Results per page (default: 20, max: 100)
* - limit: Results per page (default: 20, max: 50)
* - offset: Pagination offset (default: 0)
*/
export async function GET(request: NextRequest) {
@ -27,44 +27,52 @@ export async function GET(request: NextRequest) {
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and set defaults (reduced max from 100 to 50 for faster queries)
// Validate and set defaults
const limit = Math.min(
Number.parseInt(limitParam || '20', 10),
50 // Reduced from 100 for better performance
50 // Max 50 for better performance
);
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
// Build where clause
// Build where clause for Tool table
const where: Prisma.ToolWhereInput = {};
// Search filter (case-insensitive partial match)
// Build package filter separately
const packageFilter: Prisma.PackageWhereInput = {};
// Category filter (category is at package level)
if (category) {
packageFilter.category = category;
}
// Official filter (isOfficial is at package level)
if (officialParam !== null) {
packageFilter.isOfficial = officialParam === 'true';
}
// Search filter (searches tool description and package name)
if (query) {
where.OR = [
{ npmPackageName: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{
tags: {
hasSome: [query],
},
},
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
];
} else if (Object.keys(packageFilter).length > 0) {
// Apply package filter if no search query
where.package = packageFilter;
}
// Category filter
if (category) {
where.category = category;
}
// Official filter
if (officialParam !== null) {
where.isOfficial = officialParam === 'true';
}
// Execute queries - run count separately only if needed for pagination
// For first page, we can skip count if we don't need total pages
// Execute query - fetch tools with package relation
// We fetch limit+1 to check if there are more results (avoid expensive count)
const tools = await prisma.tool.findMany({
where,
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }, { createdAt: 'desc' }],
include: {
package: true, // Include package data for each tool
},
orderBy: [
{ qualityScore: 'desc' }, // Tool quality score
{ package: { npmDownloadsLastMonth: 'desc' } }, // Package downloads
{ createdAt: 'desc' }, // Tool creation time
],
take: limit + 1, // Fetch one extra to check if there are more
skip: offset,
});
@ -81,7 +89,6 @@ export async function GET(request: NextRequest) {
offset,
hasMore,
// Note: total count omitted for performance (can be expensive)
// Only return count if explicitly requested
},
});
} catch (error) {

View file

@ -7,22 +7,41 @@ import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
/**
* GET /api/tools/[...slug]/simulations
* GET /api/tools/simulations/[...slug]
* Returns the last 10 simulations for a tool
*
* Slug can be:
* - Tool ID (single slug)
* - Package name + export name (multiple slugs)
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
const packageName = decodeURIComponent(slug.join('/'));
try {
// Fetch tool
const tool = await prisma.tool.findUnique({
where: { npmPackageName: packageName },
select: { id: true },
});
let tool;
if (slug.length === 1) {
// Single slug - treat as tool ID
tool = await prisma.tool.findUnique({
where: { id: slug[0] || '' },
select: { id: true },
});
} else {
// Multiple slugs - treat as packageName/exportName
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
select: { id: true },
});
}
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });

View file

@ -20,24 +20,33 @@ async function getHomePageData() {
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }],
orderBy: [
{ qualityScore: 'desc' },
{ package: { npmDownloadsLastMonth: 'desc' } },
],
take: 6,
select: {
id: true,
npmPackageName: true,
exportName: true,
description: true,
category: true,
tags: true,
qualityScore: true,
npmDownloadsLastMonth: true,
isOfficial: true,
package: {
select: {
npmPackageName: true,
category: true,
npmDownloadsLastMonth: true,
isOfficial: true,
},
},
},
}),
// Category distribution for stats
prisma.tool.groupBy({
// Category distribution for stats (group by package category)
prisma.package.groupBy({
by: ['category'],
_count: true,
_count: {
_all: true,
},
}),
]);
@ -70,7 +79,7 @@ async function getHomePageData() {
featuredTools,
categories: categoryStats.slice(0, 5).map((c) => ({
name: c.category,
count: c._count,
count: c._count._all,
})),
};
} catch (error) {
@ -113,13 +122,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
{data.featuredTools.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{data.featuredTools.map((tool) => (
<Link key={tool.id} href={`/tool/${tool.npmPackageName}`} className="group">
<Link
key={tool.id}
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
className="group"
>
<div className="p-6 border border-border rounded-lg bg-surface hover:border-foreground transition-colors h-full flex flex-col">
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors">
{tool.npmPackageName}
{tool.package.npmPackageName}
<span className="text-xs text-foreground-tertiary ml-2">
({tool.exportName})
</span>
</h3>
{tool.isOfficial && (
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
@ -132,13 +148,8 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" size="sm">
{tool.category}
{tool.package.category}
</Badge>
{tool.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" size="sm">
{tag}
</Badge>
))}
</div>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
@ -147,7 +158,7 @@ export default async function HomePage(): Promise<React.ReactElement> {
{tool.qualityScore ? Number(tool.qualityScore).toFixed(2) : 'N/A'}
</span>
<span>
{tool.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
{tool.package.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
</span>
</div>
</div>

View file

@ -96,7 +96,13 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "A concise description of what your tool does"
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "myTool",
"description": "A concise description of what your tool does"
}
]
}
}`}
/>
@ -118,26 +124,32 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text",
"parameters": [
"frameworks": ["vercel-ai"],
"tools": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en')",
"required": false,
"default": "en"
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text",
"parameters": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en')",
"required": false,
"default": "en"
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score and label"
}
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score and label"
}
]
}
}`}
/>
@ -159,9 +171,7 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"frameworks": ["vercel-ai", "langchain"],
"env": [
{
"name": "SENTIMENT_API_KEY",
@ -169,15 +179,22 @@ export default function PublishPage(): React.ReactElement {
"required": true
}
],
"frameworks": ["vercel-ai", "langchain"],
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions",
"limitations": "English and Spanish only. Max 10,000 characters",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in feedback"
]
}
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions",
"limitations": "English and Spanish only. Max 10,000 characters",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in feedback"
]
}
}
]
}
}`}
/>
@ -279,26 +296,31 @@ npm publish --access public
"keywords": ["tpmjs-tool", "blog", "content"],
"tpmjs": {
"category": "text-analysis",
"description": "Creates structured blog posts with frontmatter and SEO metadata",
"parameters": [
"frameworks": ["vercel-ai", "langchain"],
"tools": [
{
"name": "title",
"type": "string",
"description": "The title of the blog post",
"required": true
},
{
"name": "content",
"type": "string",
"description": "The main content",
"required": true
"exportName": "createBlogPostTool",
"description": "Creates structured blog posts with frontmatter and SEO metadata",
"parameters": [
{
"name": "title",
"type": "string",
"description": "The title of the blog post",
"required": true
},
{
"name": "content",
"type": "string",
"description": "The main content",
"required": true
}
],
"returns": {
"type": "BlogPost",
"description": "Structured blog post with frontmatter"
}
}
],
"returns": {
"type": "BlogPost",
"description": "Structured blog post with frontmatter"
},
"frameworks": ["vercel-ai", "langchain"]
]
}
}`}
/>

View file

@ -163,7 +163,13 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
}
]
}
}`}
/>
@ -237,26 +243,32 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text",
"parameters": [
"frameworks": ["vercel-ai"],
"tools": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en', 'es')",
"required": false,
"default": "en"
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text",
"parameters": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en', 'es')",
"required": false,
"default": "en"
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
}
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
}
]
}
}`}
/>
@ -361,9 +373,7 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"frameworks": ["vercel-ai", "langchain"],
"env": [
{
"name": "SENTIMENT_API_KEY",
@ -371,15 +381,22 @@ export default function SpecPage(): React.ReactElement {
"required": true
}
],
"frameworks": ["vercel-ai", "langchain"],
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
"limitations": "English and Spanish only. Max 10,000 characters per request.",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in user feedback"
]
}
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
"limitations": "English and Spanish only. Max 10,000 characters per request.",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in user feedback"
]
}
}
]
}
}`}
/>

View file

@ -23,15 +23,17 @@ import { AppHeader } from '~/components/AppHeader';
interface Tool {
id: string;
npmPackageName: string;
npmVersion: string;
exportName: string;
description: string;
category: string;
tags: string[];
npmRepository: { url: string; type: string } | null;
qualityScore: string;
isOfficial: boolean;
npmDownloadsLastMonth: number;
package: {
npmPackageName: string;
npmVersion: string;
category: string;
npmRepository: { url: string; type: string } | null;
isOfficial: boolean;
npmDownloadsLastMonth: number;
};
}
/**
@ -43,12 +45,10 @@ export default function ToolSearchPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
// Fetch tools from API
useEffect(() => {
@ -77,21 +77,16 @@ export default function ToolSearchPage(): React.ReactElement {
setTools(fetchedTools);
setError(null);
// Extract unique categories and tags from all tools
// Extract unique categories from all tools
const categories = new Set<string>();
const tags = new Set<string>();
for (const tool of fetchedTools) {
if (tool.category) {
categories.add(tool.category);
}
for (const tag of tool.tags) {
tags.add(tag);
if (tool.package.category) {
categories.add(tool.package.category);
}
}
setAvailableCategories(Array.from(categories).sort());
setAvailableTags(Array.from(tags).sort());
} else {
setError(data.error || 'Failed to fetch tools');
}
@ -105,12 +100,6 @@ export default function ToolSearchPage(): React.ReactElement {
fetchTools();
}, [searchQuery, activeTab, categoryFilter]);
// Filter tools by selected tags (client-side)
const displayedTools =
selectedTags.length > 0
? tools.filter((tool) => selectedTags.some((tag) => tool.tags.includes(tag)))
: tools;
return (
<div className="min-h-screen bg-background">
<AppHeader />
@ -154,43 +143,18 @@ export default function ToolSearchPage(): React.ReactElement {
</div>
{/* Clear filters button */}
{(categoryFilter !== 'all' || selectedTags.length > 0) && (
{categoryFilter !== 'all' && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setCategoryFilter('all');
setSelectedTags([]);
}}
>
Clear Filters
</Button>
)}
</div>
{/* Popular tags */}
{availableTags.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="text-sm font-medium text-foreground-secondary mr-2">
Filter by tag:
</span>
{availableTags.slice(0, 10).map((tag) => (
<Badge
key={tag}
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
size="sm"
className="cursor-pointer hover:bg-foreground/10 transition-colors"
onClick={() => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}}
>
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Tabs */}
@ -200,7 +164,7 @@ export default function ToolSearchPage(): React.ReactElement {
{
id: 'featured',
label: 'Official',
count: tools.filter((t) => t.isOfficial).length,
count: tools.filter((t) => t.package.isOfficial).length,
},
]}
activeTab={activeTab}
@ -220,15 +184,22 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Tool grid */}
{!loading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedTools.length > 0 ? (
displayedTools.map((tool) => (
{tools.length > 0 ? (
tools.map((tool) => (
<Card key={tool.id} className="flex flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle>{tool.npmPackageName}</CardTitle>
{tool.npmRepository && (
<div className="flex-1">
<CardTitle>
{tool.exportName !== 'default' ? tool.exportName : tool.package.npmPackageName}
</CardTitle>
<div className="text-sm text-foreground-secondary mt-1">
{tool.package.npmPackageName}
</div>
</div>
{tool.package.npmRepository && (
<a
href={tool.npmRepository.url.replace('git+', '').replace('.git', '')}
href={tool.package.npmRepository.url.replace('git+', '').replace('.git', '')}
target="_blank"
rel="noopener noreferrer"
className="text-foreground-secondary hover:text-foreground transition-colors"
@ -244,33 +215,22 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Category badge and version */}
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" size="sm">
{tool.category}
{tool.package.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.npmVersion}</span>
{tool.isOfficial && (
<span className="text-xs text-foreground-tertiary">v{tool.package.npmVersion}</span>
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
)}
</div>
{/* Tags */}
{tool.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tool.tags.slice(0, 5).map((tag) => (
<Badge key={tag} variant="outline" size="sm">
{tag}
</Badge>
))}
</div>
)}
{/* Quality score and downloads */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground-secondary">Quality Score</span>
<span className="text-foreground-tertiary">
{tool.npmDownloadsLastMonth.toLocaleString()} downloads/mo
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
</span>
</div>
<ProgressBar
@ -289,7 +249,7 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Install command */}
<CodeBlock
code={`npm install ${tool.npmPackageName}`}
code={`npm install ${tool.package.npmPackageName}`}
language="bash"
size="sm"
showCopy={true}
@ -297,7 +257,7 @@ export default function ToolSearchPage(): React.ReactElement {
</CardContent>
<CardFooter>
<Link href={`/tool/${tool.npmPackageName}`}>
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
<Button variant="outline" size="sm" className="w-full">
View Details
</Button>

View file

@ -6,14 +6,14 @@
*/
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
import type { Tool } from '@tpmjs/db';
import type { Package, Tool } from '@tpmjs/db';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TokenBreakdown } from './TokenBreakdown';
interface ToolPlaygroundProps {
tool: Tool;
tool: Tool & { package: Package };
}
type Tab = 'input' | 'output' | 'logs' | 'tokens';
@ -47,7 +47,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
try {
const response = await fetch(
`/api/tools/execute/${encodeURIComponent(tool.npmPackageName)}`,
`/api/tools/execute/${encodeURIComponent(tool.package.npmPackageName)}/${encodeURIComponent(tool.exportName)}`,
{
method: 'POST',
headers: {
@ -194,7 +194,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
<div>
<h2 className="text-xl font-semibold text-foreground">Interactive Playground</h2>
<p className="text-sm text-foreground-secondary mt-1">
Test {tool.npmPackageName} with AI-powered execution
Test {tool.package.npmPackageName} ({tool.exportName}) with AI-powered execution
</p>
</div>
{rateLimitInfo && (

View file

@ -4,7 +4,7 @@
*/
import { openai } from '@ai-sdk/openai';
import type { Tool } from '@tpmjs/db';
import type { Package, Tool } from '@tpmjs/db';
import { executePackage } from '@tpmjs/package-executor';
import { type CoreMessage, generateText } from 'ai';
import { z } from 'zod';
@ -90,13 +90,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
/**
* Create AI SDK v6 tool definition from TPMJS Tool
* Requires Tool with Package relation
*/
export function createToolDefinition(tool: Tool) {
export function createToolDefinition(tool: Tool & { package: Package }) {
const parameters = Array.isArray(tool.parameters)
? (tool.parameters as unknown as TPMJSParameter[])
: [];
console.log('[createToolDefinition] Tool:', tool.npmPackageName);
console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.exportName);
console.log('[createToolDefinition] Parameters array:', JSON.stringify(parameters));
console.log('[createToolDefinition] Parameters length:', parameters.length);
@ -108,7 +109,9 @@ export function createToolDefinition(tool: Tool) {
console.log('[createToolDefinition] Created Zod schema:', inputSchema);
const sanitizedName = sanitizeToolName(tool.npmPackageName);
const sanitizedName = sanitizeToolName(
`${tool.package.npmPackageName}-${tool.exportName}`
);
// AI SDK v6 tool definition
return {
@ -118,9 +121,10 @@ export function createToolDefinition(tool: Tool) {
console.log('[Tool execute] Running:', sanitizedName, params);
// Execute the actual npm package in a sandbox
// Use the actual export name from the Tool record
const result = await executePackage(
tool.npmPackageName,
'default', // Most TPMJS packages export a default function
tool.package.npmPackageName,
tool.exportName, // Use actual export name (e.g., "helloWorldTool", "default")
params,
{ timeout: 5000 }
);
@ -186,15 +190,18 @@ function sanitizeToolName(npmPackageName: string): string {
/**
* Execute tool with AI agent using AI SDK v6
* Requires Tool with Package relation
*/
export async function executeToolWithAgent(
tool: Tool,
tool: Tool & { package: Package },
userPrompt: string,
onChunk?: (chunk: string) => void,
onTokenUpdate?: (tokens: Partial<TokenBreakdown>) => void
) {
const toolDef = createToolDefinition(tool);
const sanitizedToolName = sanitizeToolName(tool.npmPackageName);
const sanitizedToolName = sanitizeToolName(
`${tool.package.npmPackageName}-${tool.exportName}`
);
console.log('[executeToolWithAgent] Tool name:', sanitizedToolName);