diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills.md/route.ts b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills.md/route.ts new file mode 100644 index 0000000..30f28c3 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills.md/route.ts @@ -0,0 +1,192 @@ +/** + * GET /:username/collections/:slug/skills.md + * Generate AI-powered skills documentation for a collection + * Clean URL endpoint for machine-readable skills documentation + */ + +import { prisma } from '@tpmjs/db'; +import type { NextRequest } from 'next/server'; + +import { fetchMultiplePackageSources } from '~/lib/ai/package-source-fetcher'; +import { generateSkillsMarkdown } from '~/lib/ai/skills-generator'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 120; // Extended for source fetching + AI generation + +// Cache for 1 week +const CACHE_DURATION_SECONDS = 7 * 24 * 60 * 60; // 604800 seconds + +type RouteContext = { + params: Promise<{ username: string; slug: string }>; +}; + +/** + * GET /:username/collections/:slug/skills.md + * Generate skills.md markdown for a collection + */ +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { username: rawUsername, slug } = await context.params; + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + + // Check for cachebust query param + const cachebust = request.nextUrl.searchParams.has('cachebust'); + + // Find the user first + const user = await prisma.user.findUnique({ + where: { username }, + select: { id: true, username: true }, + }); + + if (!user || !user.username) { + return new Response('User not found', { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Find the collection by slug belonging to this user + const collection = await prisma.collection.findFirst({ + where: { + slug, + userId: user.id, + }, + include: { + tools: { + include: { + tool: { + select: { + id: true, + name: true, + description: true, + inputSchema: true, + package: { + select: { + npmPackageName: true, + npmVersion: true, + }, + }, + }, + }, + }, + orderBy: { position: 'asc' }, + take: 50, // Limit tools for performance + }, + }, + }); + + if (!collection) { + return new Response('Collection not found', { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Only return if public + if (!collection.isPublic) { + return new Response('This collection is not public', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Check cache if not busting + if (!cachebust && collection.skillsMarkdown && collection.skillsGeneratedAt) { + // Check if cache is still valid (within 1 week) + const cacheAge = Date.now() - collection.skillsGeneratedAt.getTime(); + if (cacheAge < CACHE_DURATION_SECONDS * 1000) { + return new Response(collection.skillsMarkdown, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`, + 'X-Cache': 'HIT', + 'X-Cache-Age': Math.floor(cacheAge / 1000).toString(), + }, + }); + } + } + + // Extract tools data + const tools = collection.tools.map((ct) => ({ + id: ct.tool.id, + name: ct.tool.name, + description: ct.tool.description, + packageName: ct.tool.package.npmPackageName, + inputSchema: ct.tool.inputSchema, + })); + + if (tools.length === 0) { + return new Response('Collection has no tools', { + status: 400, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Get unique packages to fetch + const uniquePackages = [ + ...new Map( + collection.tools.map((ct) => [ + ct.tool.package.npmPackageName, + { + name: ct.tool.package.npmPackageName, + version: ct.tool.package.npmVersion, + }, + ]) + ).values(), + ]; + + // Fetch package sources (limit to 5 packages for performance) + const packageSources = await fetchMultiplePackageSources(uniquePackages.slice(0, 5)); + + // Build MCP URLs + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com'; + const mcpUrls = { + http: `${baseUrl}/api/mcp/${username}/${slug}/http`, + sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`, + }; + + // Generate skills markdown + const skillsMarkdown = await generateSkillsMarkdown( + { + id: collection.id, + name: collection.name, + slug: collection.slug || slug, + description: collection.description, + username: user.username, + }, + tools, + packageSources, + mcpUrls + ); + + // Save to database for caching + await prisma.collection.update({ + where: { id: collection.id }, + data: { + skillsMarkdown, + skillsGeneratedAt: new Date(), + }, + }); + + return new Response(skillsMarkdown, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`, + 'X-Cache': 'MISS', + }, + }); + } catch (error) { + console.error('[Skills.md Error] GET /:username/collections/:slug/skills.md:', error); + + const message = + error instanceof Error ? error.message : 'Failed to generate skills documentation'; + + return new Response(`Error: ${message}`, { + status: 500, + headers: { 'Content-Type': 'text/plain' }, + }); + } +} diff --git a/apps/web/src/lib/ai/package-source-fetcher.ts b/apps/web/src/lib/ai/package-source-fetcher.ts new file mode 100644 index 0000000..0e14321 --- /dev/null +++ b/apps/web/src/lib/ai/package-source-fetcher.ts @@ -0,0 +1,333 @@ +/** + * Package Source Fetcher + * Fetches full source code for npm packages via multiple CDN sources + */ + +export interface PackageFile { + path: string; + content: string; +} + +export interface PackageSource { + packageName: string; + version: string; + files: PackageFile[]; + packageJson: Record; +} + +// Maximum files to fetch per package +const MAX_FILES_PER_PACKAGE = 10; +// Maximum file size to fetch (50KB) +const MAX_FILE_SIZE = 50 * 1024; +// Timeout for individual fetches +const FETCH_TIMEOUT_MS = 10000; + +/** + * Fetch with timeout + */ +async function fetchWithTimeout( + url: string, + timeoutMs: number = FETCH_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': 'tpmjs-skills-generator/1.0', + }, + }); + return response; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetch package.json from npm registry + */ +async function fetchPackageJson( + packageName: string, + version?: string +): Promise<{ packageJson: Record; resolvedVersion: string }> { + const registryUrl = `https://registry.npmjs.org/${packageName}`; + const response = await fetchWithTimeout(registryUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch package.json for ${packageName}: ${response.status}`); + } + + const data = await response.json(); + const resolvedVersion = version || data['dist-tags']?.latest; + + if (!resolvedVersion) { + throw new Error(`Could not resolve version for ${packageName}`); + } + + const versionData = data.versions?.[resolvedVersion]; + if (!versionData) { + throw new Error(`Version ${resolvedVersion} not found for ${packageName}`); + } + + return { packageJson: versionData, resolvedVersion }; +} + +/** + * Get list of entry point files from package.json + */ +function getEntryPoints(packageJson: Record): string[] { + const entryPoints: string[] = []; + + // Check main entry point + if (typeof packageJson.main === 'string') { + entryPoints.push(packageJson.main); + } + + // Check module entry point (ES modules) + if (typeof packageJson.module === 'string') { + entryPoints.push(packageJson.module); + } + + // Check exports field (modern packages) + if (packageJson.exports) { + const exports = packageJson.exports as Record; + extractExportPaths(exports, entryPoints); + } + + // Check types entry point + if (typeof packageJson.types === 'string') { + entryPoints.push(packageJson.types); + } + if (typeof packageJson.typings === 'string') { + entryPoints.push(packageJson.typings); + } + + // Default fallbacks if nothing found + if (entryPoints.length === 0) { + entryPoints.push('index.js', 'index.ts', 'src/index.js', 'src/index.ts'); + } + + // Dedupe and normalize paths + const normalized = [...new Set(entryPoints)].map((p) => (p.startsWith('./') ? p.slice(2) : p)); + + return normalized; +} + +/** + * Recursively extract paths from exports field + */ +function extractExportPaths(exports: Record, paths: string[]): void { + for (const value of Object.values(exports)) { + if (typeof value === 'string') { + paths.push(value); + } else if (typeof value === 'object' && value !== null) { + // Handle conditional exports like { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } + extractExportPaths(value as Record, paths); + } + } +} + +/** + * Try to fetch a file from unpkg CDN + */ +async function fetchFromUnpkg( + packageName: string, + version: string, + filePath: string +): Promise { + const url = `https://unpkg.com/${packageName}@${version}/${filePath}`; + + try { + const response = await fetchWithTimeout(url); + if (!response.ok) return null; + + // Check content length before reading + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) { + console.warn(`File too large: ${filePath} (${contentLength} bytes)`); + return null; + } + + const text = await response.text(); + if (text.length > MAX_FILE_SIZE) { + return null; + } + + return text; + } catch { + return null; + } +} + +/** + * Try to fetch a file from esm.sh CDN (with ?raw for source) + */ +async function fetchFromEsmSh( + packageName: string, + version: string, + filePath: string +): Promise { + // esm.sh requires different URL patterns for raw source + const url = `https://esm.sh/${packageName}@${version}/${filePath}?raw`; + + try { + const response = await fetchWithTimeout(url); + if (!response.ok) return null; + + const text = await response.text(); + if (text.length > MAX_FILE_SIZE) { + return null; + } + + return text; + } catch { + return null; + } +} + +/** + * Fetch a single file from CDN (tries multiple sources) + */ +async function fetchFile( + packageName: string, + version: string, + filePath: string +): Promise { + // Try unpkg first (more reliable for source files) + let content = await fetchFromUnpkg(packageName, version, filePath); + if (content) return content; + + // Try esm.sh as fallback + content = await fetchFromEsmSh(packageName, version, filePath); + if (content) return content; + + return null; +} + +/** + * Resolve a relative import path to an absolute path + */ +function resolveImportPath(importPath: string, baseDir: string): string { + if (importPath.startsWith('./')) { + return baseDir ? `${baseDir}/${importPath.slice(2)}` : importPath.slice(2); + } + + if (importPath.startsWith('../')) { + const parts = baseDir.split('/'); + const importParts = importPath.split('/'); + const upCount = importParts.filter((p) => p === '..').length; + const remaining = importParts.slice(upCount).join('/'); + const baseParts = parts.slice(0, parts.length - upCount); + return baseParts.length > 0 ? `${baseParts.join('/')}/${remaining}` : remaining; + } + + return importPath; +} + +/** + * Add file path variations with common extensions + */ +function addPathVariations(resolvedPath: string, imports: string[]): void { + if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(resolvedPath)) { + imports.push(resolvedPath); + } else { + imports.push(`${resolvedPath}.ts`); + imports.push(`${resolvedPath}.js`); + imports.push(`${resolvedPath}/index.ts`); + imports.push(`${resolvedPath}/index.js`); + } +} + +/** + * Discover additional source files from a fetched file + */ +function discoverImports(content: string, basePath: string): string[] { + const imports: string[] = []; + const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : ''; + + // Match import/export statements for local files + const importRegex = /(?:import|export).*from\s+['"](\.[^'"]+)['"]/g; + const matches = content.matchAll(importRegex); + + for (const match of matches) { + const importPath = match[1]; + if (!importPath) continue; + + const resolvedPath = resolveImportPath(importPath, baseDir); + addPathVariations(resolvedPath, imports); + } + + return imports; +} + +/** + * Fetch source files for an npm package + */ +export async function fetchPackageSource( + packageName: string, + version?: string +): Promise { + // 1. Fetch package.json to get metadata and entry points + const { packageJson, resolvedVersion } = await fetchPackageJson(packageName, version); + + // 2. Get entry points to fetch + const entryPoints = getEntryPoints(packageJson); + const filesToFetch = new Set(); + const fetchedFiles: PackageFile[] = []; + const fetchedPaths = new Set(); + + // Add entry points to fetch queue + for (const entry of entryPoints) { + filesToFetch.add(entry); + } + + // 3. Fetch files breadth-first, discovering imports + const queue = [...filesToFetch]; + + while (queue.length > 0 && fetchedFiles.length < MAX_FILES_PER_PACKAGE) { + const filePath = queue.shift(); + if (!filePath) break; + + // Skip if already fetched + if (fetchedPaths.has(filePath)) continue; + fetchedPaths.add(filePath); + + // Try to fetch the file + const content = await fetchFile(packageName, resolvedVersion, filePath); + if (!content) continue; + + fetchedFiles.push({ path: filePath, content }); + + // Discover imports and add to queue + const imports = discoverImports(content, filePath); + for (const importPath of imports) { + if (!fetchedPaths.has(importPath) && !queue.includes(importPath)) { + queue.push(importPath); + } + } + } + + return { + packageName, + version: resolvedVersion, + files: fetchedFiles, + packageJson, + }; +} + +/** + * Fetch sources for multiple packages concurrently + */ +export async function fetchMultiplePackageSources( + packages: Array<{ name: string; version?: string }> +): Promise { + const results = await Promise.allSettled( + packages.map(({ name, version }) => fetchPackageSource(name, version)) + ); + + return results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); +} diff --git a/apps/web/src/lib/ai/skills-generator.ts b/apps/web/src/lib/ai/skills-generator.ts new file mode 100644 index 0000000..0dd1386 --- /dev/null +++ b/apps/web/src/lib/ai/skills-generator.ts @@ -0,0 +1,299 @@ +/** + * AI-powered skills documentation generator for tool collections + * Uses Vercel AI SDK to analyze package source code and generate comprehensive skills.md + */ + +import { openai } from '@ai-sdk/openai'; +import { generateText } from 'ai'; + +import type { PackageSource } from './package-source-fetcher'; + +export interface CollectionData { + id: string; + name: string; + slug: string; + description: string | null; + username: string; +} + +export interface ToolData { + id: string; + name: string; + description: string; + packageName: string; + inputSchema: unknown | null; +} + +export interface McpUrls { + http: string; + sse: string; +} + +const SKILLS_SYSTEM_PROMPT = `You are an expert technical writer generating machine-consumable skills documentation for AI agents. + +Your task is to analyze actual source code from npm packages and generate a comprehensive skills.md document that serves as a capability contract for AI agents like Claude Code, MCP clients, and tool routers. + +Guidelines: +1. **Accuracy First**: Only document capabilities you can verify from the source code +2. **Real Examples**: Generate code examples based on actual function signatures and patterns in the source +3. **Input/Output Schemas**: Extract exact TypeScript types when available +4. **Source Analysis**: Provide insights about implementation details that help agents use tools correctly +5. **Integration Focus**: Show multiple ways to integrate (ESM, NPM, CLI, REST API, MCP) +6. **Constraints**: Document what tools can and cannot do based on code analysis + +Format the output as clean, well-organized Markdown with proper code blocks and syntax highlighting.`; + +function buildSkillsPrompt( + collection: CollectionData, + tools: ToolData[], + packageSources: PackageSource[], + mcpUrls: McpUrls +): string { + const baseUrl = mcpUrls.http.replace(/\/api\/mcp\/.*$/, ''); + const collectionUrl = `${baseUrl}/${collection.username}/collections/${collection.slug}`; + + // Build package source context + const sourceContext = packageSources + .map((pkg) => { + const filesContent = pkg.files + .map((f) => `### ${f.path}\n\`\`\`typescript\n${f.content.slice(0, 5000)}\n\`\`\``) + .join('\n\n'); + + return `## Package: ${pkg.packageName}@${pkg.version} + +**package.json exports:** +\`\`\`json +${JSON.stringify( + { + main: pkg.packageJson.main, + module: pkg.packageJson.module, + exports: pkg.packageJson.exports, + types: pkg.packageJson.types, + }, + null, + 2 +)} +\`\`\` + +**Source Files:** +${filesContent}`; + }) + .join('\n\n---\n\n'); + + // Build tools context + const toolsContext = tools + .map( + (t) => `- **${t.name}** (${t.packageName}) + Description: ${t.description} + Input Schema: ${t.inputSchema ? JSON.stringify(t.inputSchema, null, 2) : 'Not available'}` + ) + .join('\n\n'); + + return `Generate a skills.md document for this collection. + +## Collection Info +- **Name:** ${collection.name} +- **Owner:** @${collection.username} +- **Slug:** ${collection.slug} +- **Description:** ${collection.description || 'No description'} +- **Collection URL:** ${collectionUrl} + +## MCP Endpoints +- **HTTP:** ${mcpUrls.http} +- **SSE:** ${mcpUrls.sse} + +## Tools in Collection (${tools.length} total) +${toolsContext} + +## Package Source Code +Analyze the following source code to understand tool implementations: + +${sourceContext} + +--- + +Generate the skills.md document following this EXACT structure: + +# Agent Skills Declaration: ${collection.name} + +> Machine-consumable capability contract for AI agents (Claude Code, MCP clients, tool routers) + +## 1. Agent Identity + +**Collection:** ${collection.name} +**Owner:** @${collection.username} +**Primary Role:** [Derive from tools and description] +**Tool Count:** ${tools.length} tools from ${new Set(tools.map((t) => t.packageName)).size} packages + +--- + +## 2. Core Skills + +[For each tool, include:] +### Skill: [tool.name] +**Package:** \`[package.npmPackageName]\` +**Description:** [tool.description] + +**Input Schema:** +\`\`\`typescript +[Extract from source code] +\`\`\` + +**Output Format:** [Analyze from source] + +**Source Analysis:** +[AI-generated insights from reading actual code - what does this tool actually do internally?] + +**Example Usage:** +\`\`\`typescript +[Real example based on source code patterns] +\`\`\` + +--- + +## 3. Direct Usage (Without MCP/CLI/REST) + +### ESM Import (Browser/Deno) +\`\`\`typescript +[Generate real ESM import examples using esm.sh] +\`\`\` + +### NPM Import (Node.js) +\`\`\`typescript +[Generate real NPM import examples] +\`\`\` + +### Multi-Tool Workflow +\`\`\`typescript +[Generate a workflow example combining multiple tools] +\`\`\` + +--- + +## 4. Agent Integration Methods + +### 4.1 CLI (\`tpm run\`) + +Install CLI: +\`\`\`bash +npm install -g @tpmjs/cli +\`\`\` + +Run a tool: +\`\`\`bash +tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name] --args '{"key": "value"}' +\`\`\` + +With environment variables: +\`\`\`bash +SOME_API_KEY=xxx tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name] +\`\`\` + +### 4.2 REST API (JSON-RPC 2.0) + +**Endpoint:** \`POST ${mcpUrls.http}\` + +\`\`\`bash +curl -X POST ${mcpUrls.http} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "[mcp-tool-name]", + "arguments": { ... } + } + }' +\`\`\` + +**List available tools:** +\`\`\`bash +curl -X POST ${mcpUrls.http} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' +\`\`\` + +### 4.3 MCP Server + +**URLs:** +- HTTP: \`${mcpUrls.http}\` +- SSE: \`${mcpUrls.sse}\` + +**Claude Desktop Config (\`claude_desktop_config.json\`):** +\`\`\`json +{ + "mcpServers": { + "${collection.slug}": { + "url": "${mcpUrls.sse}", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +\`\`\` + +**Local MCP Server (via CLI):** +\`\`\`bash +tpm mcp serve ${collection.username}/${collection.slug} +\`\`\` + +--- + +## 5. Constraints & Safety + +**This collection does NOT:** +[Generate based on tool analysis - what are the limitations?] + +**Rate Limits:** +- Free tier: 100 requests/hour +- Authenticated: 1000 requests/hour + +**Authentication:** +- Public collections: No auth required for read +- Tool execution: API key required + +--- + +## 6. Versioning + +**Skills Version:** 1.0.0 +**Generated:** [Current timestamp] +**Collection Updated:** [Will be filled in] + +--- + +## 7. Canonical References + +- TPMJS: https://tpmjs.com +- MCP Protocol: https://modelcontextprotocol.io +- Collection Page: ${collectionUrl}`; +} + +/** + * Generate skills.md markdown for a collection + */ +export async function generateSkillsMarkdown( + collection: CollectionData, + tools: ToolData[], + packageSources: PackageSource[], + mcpUrls: McpUrls +): Promise { + if (tools.length === 0) { + throw new Error('Collection must have at least one tool to generate skills documentation'); + } + + const prompt = buildSkillsPrompt(collection, tools, packageSources, mcpUrls); + + const { text } = await generateText({ + model: openai('gpt-4.1-mini'), + system: SKILLS_SYSTEM_PROMPT, + prompt, + temperature: 0.3, // Lower temperature for more factual output + }); + + return text; +} diff --git a/docs/PRD-omega.md b/docs/PRD-omega.md new file mode 100644 index 0000000..309c75b --- /dev/null +++ b/docs/PRD-omega.md @@ -0,0 +1,287 @@ +# Omega PRD (Draft v0.1) + +Project name: Omega +Path: `/omega` +Owner: TPMJS +Status: Draft + +## 1) Executive Summary +Omega is a TPMJS side project that demonstrates what happens when a single agent can discover and execute tools from the entire TPM registry. The vision is playful and bold: "the last robot/AI you ever need" (Skynet energy, but friendly). Omega will ship as a dedicated chat experience at `/omega` and a public REST API that lets anyone start and continue conversations. The product must highlight tool discovery, selection, and execution at scale, while handling missing credentials gracefully. + +## 2) Problem Statement +TPMJS has powerful registry and tool execution capabilities, but the end-user experience does not yet show how these components combine into a single, continuous, tool-driven agent. Users need a simple way to: +- Ask for outcomes, not tools +- Watch the agent discover the right tool(s) +- Provide credentials only when needed +- Continue execution without restarting + +## 3) Vision +Omega is a single agent with access to the TPM registry, able to choose and run the right tools to complete tasks. Users can chat or call an API and see a transparent, step-by-step tool workflow. It is a public showcase that can become a standalone product over time. + +## 4) Goals and Success Metrics +### Goals +- Showcase tool discovery and execution at scale (1M+ tools). +- Deliver a great developer experience (DX) with simple API endpoints. +- Make credential handling safe, clear, and resumable. +- Provide a polished chat UI that is fast and transparent. + +### Success Metrics (Early) +- Conversations created per week +- % conversations that use at least one tool +- Median time to first tool call +- Completion rate (user gets a result without abandoning) +- Avg. tool-call count per conversation +- User feedback: "felt like a real agent" + +## 5) Non-Goals (v0) +- Multi-agent orchestration +- Enterprise SSO or complex permission tiers +- Guaranteed deterministic tool selection +- Complex billing or metering + +## 6) Target Users and Use Cases +### Personas +- Developers evaluating TPMJS or building on it (primary for v0) +- Power users exploring tool chains +- Curious users experimenting with AI + tools + +### Example Use Cases +- "Summarize a website and turn it into a checklist" +- "Analyze a CSV and generate insights" +- "Generate a proposal and send it via email" +- "Search for a tool to do X and run it" + +## 7) Product Requirements + +### 7.1 Chat UI (`/omega`) +- Landing intro before first chat +- New conversation +- Message stream with agent responses +- Tool activity panel (tool name, inputs, outputs, status) +- Debug mode (raw JSON) and "view raw" toggles for tool I/O +- Shareable conversation URL +- Requires login (v0) +- Credential prompt when missing env vars +- Resume execution after credentials are provided +- Planning panel for proposed plans (only when requested or task is complex) +- Cancel running tool execution or full conversation run +- Multi-user conversations: shared links allow anyone with URL to send messages +- Show participant display names and Gravatar avatars (email-based) +- Export conversation to JSON and Markdown +- Lazy-load long outputs and message history (Virtuoso-based) +- Auto-generate conversation title after first response +- System status banner (registry + executor health) +- Empty state includes creative sample prompts +- Light/dark theme supported (use TPMJS style guide) + +### 7.2 REST API +Endpoints (initial): +- `POST /api/omega/conversations` + Create a conversation. Returns conversation ID and optional title. +- `POST /api/omega/conversations/:id/messages` + Send a message and stream response (SSE). +- `GET /api/omega/conversations/:id` + Fetch conversation history. +- `POST /api/omega/conversations/:id/credentials` + Provide missing env vars and resume. +- `POST /api/omega/conversations/:id/plan` + Optional: generate plan(s) without execution. +- API access requires TPMJS API key (standard user keys). +- Shared conversations accept either logged-in session or TPMJS API key. + +### 7.3 Tool Discovery and Execution +- Agent can search registry and execute tools. +- Default path: registry search + execute tools (entire registry). +- Registry execution uses TPMJS sandbox executor. +- Allow tool usage transparency in UI and API responses. +- Allow users to pin preferred tools for the agent to prioritize. +- Pinned tools are always included in the plan/tool set. +- Max tool calls per message: 200 (AI SDK `maxSteps`). + +### 7.6 System Prompt and Safety Controls +- Omega ships with a comprehensive default system prompt. +- Users can extend/override with a custom prompt in Omega settings. +- Safe Mode toggle: require explicit approval before tool execution (human-in-the-loop). + +### 7.4 Planning +- Agent can propose one or more plans before running tools. +- Plans show tool order, purpose, expected outputs, and confidence. +- User can select a plan or ask for another. +- Planning can be auto-run for complex tasks or when explicitly requested. + +### 7.5 Credentials / Missing Env Vars +- Detect missing env vars for tools. +- Pause execution when required env vars missing. +- Provide a credential prompt in UI (inline modal). +- Resume execution after credentials are supplied. +- Allow API callers to supply env vars in requests. +- Store credentials encrypted per-user in Omega settings. + +## 8) UX Flows + +### Flow A: Standard Chat +1. User sends a message. +2. Agent responds, streams tokens. +3. Tool calls appear in tool panel. +4. Tool results appear inline. +5. Conversation persists. + +### Flow B: Missing Credentials +1. Tool requires env vars. +2. Agent pauses and returns `MISSING_ENV_VARS`. +3. UI displays required keys and descriptions. +4. User provides keys. +5. Execution resumes from same step. + +### Flow C: Planning +1. User sends a request. +2. Agent responds with one or more plans. +3. User selects a plan or asks for changes. +4. Execution starts. + +### Flow D: Tool Failure + Alternatives +1. Tool fails or missing credentials. +2. Agent searches for alternative tools. +3. Agent presents options and required keys in a table. +4. User selects a tool; execution resumes. + +## 9) Data Model (High-Level) +- Conversation: id, userId (optional), createdAt, updatedAt, title +- Message: id, conversationId, role, content, createdAt +- ToolRun: id, conversationId, messageId, toolId, input, output, status, duration +- Plan: id, conversationId, messageId, steps, selectedPlanId, createdAt +- CredentialRequest: id, conversationId, toolRunId, requiredKeys, status, createdAt +- CredentialStore: userId, keyName, encryptedValue, scope (global/tool) + +## 10) Security and Privacy +- Encrypt stored credentials at rest. +- Never expose full secret values in UI or logs. +- Redact secrets from tool outputs when needed. +- Rate limit API endpoints to prevent abuse. +- Log rate limit events (even if limits are effectively unlimited at launch). +- Audit logs for tool calls and credential access. + +## 11) Technical Foundations (Reuse Existing) +From current codebase: +- Chat + SSE (agent conversations, tool-call streaming, message persistence): + `apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts` +- Pretty URL variant for agent chat (API key scopes + owner/public checks): + `apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts` +- Chat UI with tool panel + streaming + Virtuoso pagination: + `apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx` +- Tool execution wrapper + AI SDK tool definition builder: + `apps/web/src/lib/ai-agent/tool-executor-agent.ts` +- Executor resolution + sandbox execution: + `apps/web/src/lib/executors/index.ts` +- Registry meta-tools for search + execute: + `packages/tools/registrySearch`, `packages/tools/registryExecute` +- Env var detection and missing var reporting: + `apps/web/src/lib/agents/env-helpers.ts` +- Env var cascade and caller-provided env support: + `apps/web/src/lib/agents/build-tools.ts` +- MCP handler with env-var validation for non-owners: + `apps/web/src/lib/mcp/handlers.ts` +- Credential encryption utilities and API key storage: + `apps/web/src/lib/crypto/api-keys.ts`, `apps/web/src/app/api/user/api-keys/route.ts` +- Existing planning tool for generatePlans: + `packages/tools/official/tool-selection-plan` + +## 12) Product Decisions (Initial) +- Primary audience: developers. +- Auth: login required (v0). +- Tool access: entire registry via registry search + execute. +- Model: default `gpt-4.1-mini`; users choose provider/model if they have keys. +- Planning: only on explicit request or when task complexity is high. +- Credentials: stored per-user, scoped to Omega, managed in Omega settings. +- Safety: no additional tool restrictions beyond existing TPMJS policies. +- Sharing: anyone with conversation URL can participate. +- Retention: conversations are stored indefinitely. +- Tool output: summarized by default with "view raw" toggle. +- Cancel: users can stop a running tool or full run. +- Rate limits: system in place, effectively unlimited at launch. +- Participants: show display names + Gravatar avatars. +- API auth: requires standard TPMJS API key. +- Tool selection UX: agent-only (no manual marketplace UI for v0). +- Files: uploads deferred to a later phase. +- Streaming: stream tool outputs where supported by the AI SDK. +- UI: follow TPMJS style guide (no bespoke theme for v0). +- Conversation IDs: UUIDs. +- Credentials: per-user only (no team/workspace creds in v0). +- Conversation context: keep full history (no auto-summarization in v0). +- Compliance: no special compliance requirements at launch. +- Tool budget indicator: not required. +- Shared conversation execution: only owner-stored credentials may run tools. +- Large outputs: lazy-load via virtualization. +- Export: JSON and Markdown supported. +- Memory: per-conversation only. +- Conversation titles: auto-generated after first response (AI SDK). +- Reactions: not required for v0. +- Tool reasoning: show only when model returns reasoning tokens. +- Deletion: users cannot delete conversations in v0. +- API keys: use existing TPMJS API keys only. +- Discovery: conversations are not listed; URL-only access. +- Context control: no reset button, use new chat instead. +- Tool allowlist: no allowlist (pins only). +- Audit/log retention: store all tool I/O indefinitely. +- Shared chat auth: accept session auth or TPMJS API key. +- Landing: intro view before first chat. +- Empty state: creative sample prompts. +- Theme: light/dark via style guide. + +## 13) API Response Contracts (Draft) +### Missing Env Vars +```json +{ + "success": false, + "error": "Missing required environment variables", + "details": { + "code": "MISSING_ENV_VARS", + "missingVars": [ + { "name": "FIRECRAWL_API_KEY", "description": "API key for Firecrawl" } + ], + "hint": "Provide these variables in the 'env' field of your request" + } +} +``` + +### Plan Response +```json +{ + "plan": [ + { "stepNumber": 1, "toolName": "registrySearchTool", "purpose": "Find tools for web scraping" }, + { "stepNumber": 2, "toolName": "registryExecuteTool", "purpose": "Run the chosen tool" } + ], + "confidence": 0.74, + "reasoning": "Found 2 relevant tools for this task" +} +``` + +## 14) Performance and Reliability +- SSE streams should stay responsive under load. +- Tool execution timeouts (default 5 minutes). +- Concurrency controls per conversation. +- Circuit breaker for broken tools or executors. + +## 15) Observability +- Log tool calls and errors. +- Track token usage per conversation. +- Surface tool success/failure rates. +- Store conversation logs for debugging. + +## 16) Risks and Mitigations +- Tool quality variance: provide confidence and fallbacks. +- Missing credentials: pause/resume flow with clear UX. +- Abuse risk: rate limiting + auth. +- Tool execution cost: usage tracking + limits. + +## 17) Rollout Plan +1. Internal alpha (team-only) +2. Public beta with rate limits +3. Full public launch + +## 18) Open Questions +- System prompt ownership and defaults (versioning and updates)? +- Safe Mode default on/off? +- How many plans should Omega propose by default? + - Webhook support for API events (future). + - Profiles for system prompts (future). diff --git a/packages/cli/README.md b/packages/cli/README.md index 9a60d56..cb6c3f2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,6 +72,24 @@ tpm collection add # Add tools to a collection tpm collection remove # Remove tools from a collection ``` +### Run Tools + +Execute tools directly from collections via the MCP API: + +```bash +# Basic usage +tpm run -c ajax/unsandbox -t execute --args '{"code":"print(1)","language":"python"}' + +# With environment variables +tpm run -c ajax/my-collection -t myTool --env API_KEY=xxx --env DEBUG=true + +# Using process environment +OPENAI_API_KEY=xxx tpm run -c ajax/ai-tools -t generate + +# JSON output for scripting +tpm run -c ajax/tools -t base64Encode --args '{"data":"hello"}' --json | jq .result +``` + ### MCP Integration ```bash diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts new file mode 100644 index 0000000..c632240 --- /dev/null +++ b/packages/cli/src/commands/run.ts @@ -0,0 +1,321 @@ +import { Command, Flags } from '@oclif/core'; + +import { getApiKey, getApiUrl } from '../lib/config.js'; +import { createOutput, type OutputFormatter } from '../lib/output.js'; + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: { + content?: Array<{ type: string; text: string }>; + [key: string]: unknown; + }; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +interface RunFlags { + collection: string; + tool: string; + args: string; + env?: string[]; + json: boolean; + verbose: boolean; + timeout: number; +} + +/** + * Parse and validate collection identifier + */ +function parseCollection(collection: string): { username: string; slug: string } | null { + const parts = collection.split('/'); + if (parts.length !== 2) return null; + const [username, slug] = parts; + if (!username || !slug) return null; + return { username, slug }; +} + +/** + * Parse JSON tool arguments + */ +function parseToolArgs(argsStr: string): Record | null { + try { + return JSON.parse(argsStr) as Record; + } catch { + return null; + } +} + +/** + * Build environment variables from process.env and explicit flags + */ +function buildEnvVars(envFlags: string[] | undefined, output: OutputFormatter): Record { + const envVars: Record = {}; + + // Add process env vars that might be relevant + for (const [key, value] of Object.entries(process.env)) { + if (value && (key.endsWith('_API_KEY') || key.endsWith('_TOKEN') || key.endsWith('_SECRET'))) { + envVars[key] = value; + } + } + + // Add explicitly passed env vars (override process env) + if (envFlags) { + for (const envPair of envFlags) { + const [key, ...valueParts] = envPair.split('='); + if (key && valueParts.length > 0) { + envVars[key] = valueParts.join('='); + } else { + output.warning(`Invalid env var format: ${envPair}. Use: KEY=value`); + } + } + } + + return envVars; +} + +/** + * Display verbose request info + */ +function displayVerboseInfo( + output: OutputFormatter, + mcpUrl: string, + tool: string, + toolArgs: Record, + envVars: Record +): void { + output.info(`MCP Endpoint: ${mcpUrl}`); + output.info(`Tool: ${tool}`); + output.info(`Arguments: ${JSON.stringify(toolArgs, null, 2)}`); + if (Object.keys(envVars).length > 0) { + output.info(`Environment: ${Object.keys(envVars).join(', ')}`); + } + output.divider(); +} + +/** + * Handle HTTP error response + */ +async function handleHttpError(response: Response, output: OutputFormatter): Promise { + const errorText = await response.text(); + try { + const errorJson = JSON.parse(errorText) as { error?: { message?: string }; message?: string }; + output.error(errorJson.error?.message || errorJson.message || `HTTP ${response.status}`); + } catch { + output.error(`HTTP ${response.status}: ${errorText}`); + } +} + +/** + * Display MCP response content + */ +function displayContent(result: JsonRpcResponse['result'], output: OutputFormatter): void { + if (!result) { + output.text('(empty result)'); + return; + } + + if (result.content && Array.isArray(result.content)) { + for (const item of result.content) { + displayContentItem(item, output); + } + } else { + output.text(JSON.stringify(result, null, 2)); + } +} + +/** + * Display a single content item + */ +function displayContentItem(item: { type: string; text: string }, output: OutputFormatter): void { + if (item.type === 'text') { + try { + const parsed = JSON.parse(item.text); + output.text(JSON.stringify(parsed, null, 2)); + } catch { + output.text(item.text); + } + } else if (item.type === 'image') { + output.info('[Image content]'); + } else { + output.text(JSON.stringify(item, null, 2)); + } +} + +/** + * Handle execution error + */ +function handleExecutionError(error: unknown, flags: RunFlags, output: OutputFormatter): void { + if (error instanceof Error) { + if (error.name === 'AbortError') { + output.error(`Request timed out after ${flags.timeout} seconds`); + } else { + output.error(error.message); + } + } else { + output.error('Unknown error occurred'); + } + + if (flags.verbose) { + output.info(`Full error: ${String(error)}`); + } +} + +export default class Run extends Command { + static description = 'Execute a tool from a collection via MCP'; + + static examples = [ + '<%= config.bin %> run -c ajax/unsandbox -t execute --args \'{"code":"print(1)","language":"python"}\'', + '<%= config.bin %> run --collection ajax/ajax-collection --tool base64Encode --args \'{"data":"hello"}\'', + 'OPENAI_API_KEY=xxx <%= config.bin %> run -c ajax/my-collection -t myTool', + '<%= config.bin %> run -c ajax/tools -t search --args \'{"query":"test"}\' --json', + '<%= config.bin %> run -c ajax/tools -t search --env API_KEY=xxx --env DEBUG=true', + ]; + + static flags = { + collection: Flags.string({ + char: 'c', + description: 'Collection identifier (username/slug)', + required: true, + }), + tool: Flags.string({ + char: 't', + description: 'Tool name (MCP format: package--toolName)', + required: true, + }), + args: Flags.string({ + char: 'a', + description: 'Tool arguments as JSON string', + default: '{}', + }), + env: Flags.string({ + char: 'e', + description: 'Environment variables (key=value)', + multiple: true, + }), + json: Flags.boolean({ + description: 'Output result as JSON', + default: false, + }), + verbose: Flags.boolean({ + char: 'v', + description: 'Show verbose output', + default: false, + }), + timeout: Flags.integer({ + description: 'Request timeout in seconds', + default: 60, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Run); + const output = createOutput(flags); + + // Parse and validate inputs + const collection = parseCollection(flags.collection); + if (!collection) { + output.error('Invalid collection format. Use: username/slug'); + return; + } + + const toolArgs = parseToolArgs(flags.args); + if (!toolArgs) { + output.error('Invalid JSON in --args flag'); + return; + } + + // Build request components + const envVars = buildEnvVars(flags.env, output); + const baseUrl = getApiUrl().replace(/\/api$/, ''); + const mcpUrl = `${baseUrl}/api/mcp/${collection.username}/${collection.slug}/http`; + + const apiKey = getApiKey(); + if (!apiKey) { + output.warning('No API key configured. Some operations may fail.'); + output.warning('Run `tpm auth login` to authenticate.'); + } + + if (flags.verbose) { + displayVerboseInfo(output, mcpUrl, flags.tool, toolArgs, envVars); + } + + // Execute the request + await this.executeToolCall(flags, output, mcpUrl, apiKey, toolArgs, envVars); + } + + private async executeToolCall( + flags: RunFlags, + output: OutputFormatter, + mcpUrl: string, + apiKey: string | undefined, + toolArgs: Record, + envVars: Record + ): Promise { + const jsonRpcRequest = { + jsonrpc: '2.0', + id: Date.now(), + method: 'tools/call', + params: { + name: flags.tool, + arguments: toolArgs, + ...(Object.keys(envVars).length > 0 ? { env: envVars } : {}), + }, + }; + + const spinner = output.spinner(`Executing ${flags.tool}...`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), flags.timeout * 1000); + + const response = await fetch(mcpUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify(jsonRpcRequest), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + spinner.fail('Request failed'); + await handleHttpError(response, output); + return; + } + + const result = (await response.json()) as JsonRpcResponse; + spinner.stop(); + + this.handleResult(flags, output, result); + } catch (error) { + spinner.fail('Execution failed'); + handleExecutionError(error, flags, output); + } + } + + private handleResult(flags: RunFlags, output: OutputFormatter, result: JsonRpcResponse): void { + if (result.error) { + output.error(`Tool execution failed: ${result.error.message}`); + if (flags.verbose && result.error.data) { + output.info(`Error data: ${JSON.stringify(result.error.data, null, 2)}`); + } + return; + } + + if (flags.json) { + output.json(result.result); + return; + } + + output.success('Tool execution complete'); + output.divider(); + displayContent(result.result, output); + } +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 03953b9..c5ef34b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -458,6 +458,10 @@ model Collection { useCases Json? @map("use_cases") @db.JsonB useCasesGeneratedAt DateTime? @map("use_cases_generated_at") + // AI-generated skills documentation + skillsMarkdown String? @map("skills_markdown") @db.Text + skillsGeneratedAt DateTime? @map("skills_generated_at") + // Timestamps createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at")