diff --git a/bun.lock b/bun.lock index 2c8ebd05..b2d17e79 100644 --- a/bun.lock +++ b/bun.lock @@ -117,6 +117,22 @@ "@tanstack/react-query", ], }, + "packages/catalog": { + "name": "@lucid-agents/catalog", + "version": "0.1.0", + "dependencies": { + "@lucid-agents/types": "workspace:*", + "csv-parse": "^5.6.0", + "yaml": "^2.7.1", + "zod": "catalog:", + }, + "devDependencies": { + "@lucid-agents/core": "workspace:*", + "@lucid-agents/http": "workspace:*", + "tsup": "catalog:", + "typescript": "catalog:", + }, + }, "packages/cli": { "name": "@lucid-agents/cli", "version": "2.5.0", @@ -173,6 +189,7 @@ "@lucid-agents/a2a": "workspace:*", "@lucid-agents/analytics": "workspace:*", "@lucid-agents/ap2": "workspace:*", + "@lucid-agents/catalog": "workspace:*", "@lucid-agents/core": "workspace:*", "@lucid-agents/hono": "workspace:*", "@lucid-agents/http": "workspace:*", @@ -702,6 +719,8 @@ "@lucid-agents/api-sdk": ["@lucid-agents/api-sdk@workspace:packages/api-sdk"], + "@lucid-agents/catalog": ["@lucid-agents/catalog@workspace:packages/catalog"], + "@lucid-agents/cli": ["@lucid-agents/cli@workspace:packages/cli"], "@lucid-agents/core": ["@lucid-agents/core@workspace:packages/core"], @@ -1616,6 +1635,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "csv-parse": ["csv-parse@5.6.0", "", {}, "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], diff --git a/lucid-docs/content/docs/packages/catalog.mdx b/lucid-docs/content/docs/packages/catalog.mdx new file mode 100644 index 00000000..2271b1fd --- /dev/null +++ b/lucid-docs/content/docs/packages/catalog.mdx @@ -0,0 +1,369 @@ +--- +title: '@lucid-agents/catalog' +description: YAML/CSV catalog-driven route generation for large product catalogs. +icon: List +--- + +The catalog extension generates entrypoint routes from YAML or CSV product catalog files. Instead of writing hundreds of `addEntrypoint()` calls, define your catalog in a single file and let the extension register all routes at build time. + +## Installation + +```bash +bun add @lucid-agents/catalog +``` + +## Basic usage + +Define a product catalog in YAML: + +```yaml +# products.yaml +products: + - key: sentiment + name: Sentiment Analysis + description: Analyze text sentiment + price: "0.05" + - key: summarize + name: Text Summarizer + description: Summarize long-form content + price: + invoke: "0.50" + stream: "0.10" + - key: health + name: Health Check + description: Free health endpoint +``` + +Wire it into your agent: + +```typescript +import { createAgent } from '@lucid-agents/core'; +import { http } from '@lucid-agents/http'; +import { catalog } from '@lucid-agents/catalog'; +import { createAgentApp } from '@lucid-agents/hono'; + +const agent = await createAgent({ + name: 'store-agent', + version: '1.0.0', +}) + .use(http()) + .use(catalog({ file: './products.yaml' })) + .build(); + +const { app } = await createAgentApp(agent); +``` + +Each item in the YAML file becomes a discoverable, invocable entrypoint on your agent. Items with a `price` field will require payment when combined with the payments or MPP extensions. + +## YAML format + +YAML catalogs support a `products` wrapper object or a top-level array: + +```yaml +products: + - key: translate + name: Quick Translate + description: Translate text between languages + price: "0.10" + network: "eip155:8453" + metadata: + category: nlp + rateLimit: 200 +``` + +### Supported fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | `string` | Yes | Unique route identifier. Becomes the entrypoint key. | +| `name` | `string` | Yes | Human-readable display name. | +| `description` | `string` | No | Description shown in the agent manifest. | +| `price` | `string` or `object` | No | Flat USD price, or `{ invoke, stream }` for per-mode pricing. | +| `network` | `string` | No | CAIP-2 network identifier (e.g., `eip155:8453`). | +| `metadata` | `object` | No | Arbitrary key-value pairs attached to the entrypoint. | + +### Price variants + +Flat price (same for invoke and stream): + +```yaml +- key: summarize + name: Summarize + price: "0.50" +``` + +Separate invoke and stream prices: + +```yaml +- key: generate + name: Generate Text + price: + invoke: "1.00" + stream: "0.20" +``` + +No price (free entrypoint): + +```yaml +- key: health + name: Health Check +``` + +## CSV format + +CSV catalogs use a header row. Metadata columns use the `meta_` prefix convention: + +```csv +key,name,description,price,network,meta_category,meta_tier +ocr,OCR Scan,Extract text from images,0.50,,vision,standard +embed,Text Embedding,Generate vector embeddings,0.02,,ml,basic +classify,Text Classifier,Classify text,0.10,,nlp,basic +``` + +| Column | Required | Description | +|--------|----------|-------------| +| `key` | Yes | Unique route identifier. | +| `name` | Yes | Display name. | +| `description` | No | Route description. | +| `price` | No | USD price string. Empty = free. | +| `network` | No | CAIP-2 network identifier. | +| `meta_*` | No | Collected into `metadata` with prefix stripped. `meta_category` becomes `metadata.category`. | + + +CSV does not support the `{ invoke, stream }` price object syntax. Use YAML if you need per-mode pricing. + + +## Configuration + +### CatalogExtensionOptions + +```typescript +type CatalogExtensionOptions = { + file: string; // Path to .yaml, .yml, or .csv file + keyPrefix?: string; // Prefix for all item keys + network?: string; // Default CAIP-2 network + handlerFactory?: HandlerFactory; // Generate handlers from items + inputSchema?: z.ZodTypeAny; // Custom input schema for all routes +}; +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `file` | (required) | Path to the catalog file. Supports `.yaml`, `.yml`, and `.csv`. | +| `keyPrefix` | `undefined` | Prepended to every item key. `"store/"` turns `"widget"` into `"store/widget"`. | +| `network` | `undefined` | Default network for items that don't specify their own. | +| `handlerFactory` | `undefined` | Function receiving a `CatalogItem`, returning a handler function. | +| `inputSchema` | `z.object({ params: z.record(...).optional() })` | Zod schema applied as input to all generated entrypoints. | + +### Handler factory + +The handler factory generates a route handler for each catalog item. This is where you connect catalog items to actual business logic: + +```typescript +import type { CatalogItem } from '@lucid-agents/catalog'; + +const handlerFactory = (item: CatalogItem) => { + return async (ctx: { input: { params?: Record } }) => { + const model = item.metadata?.model as string ?? 'gpt-4o-mini'; + + // Route to different backends based on catalog metadata + const result = await callModel(model, ctx.input); + + return { + output: { + product: item.key, + result, + }, + }; + }; +}; + +agent.use(catalog({ + file: './products.yaml', + handlerFactory, +})); +``` + +### Key prefix + +Use `keyPrefix` to namespace routes, especially when loading multiple catalogs: + +```typescript +const agent = await createAgent({ name: 'multi-catalog', version: '1.0.0' }) + .use(http()) + .use(catalog({ file: './nlp-services.yaml', keyPrefix: 'nlp/' })) + .use(catalog({ file: './vision-services.csv', keyPrefix: 'vision/' })) + .build(); +``` + +This produces routes like `nlp/sentiment`, `nlp/translate`, `vision/classify`. + +## Integration with payments + +### With x402 + +Combine with the payments extension for x402 payment gating. Any catalog item with a `price` field will automatically require payment: + +```typescript +import { payments, paymentsFromEnv } from '@lucid-agents/payments'; +import { catalog } from '@lucid-agents/catalog'; + +const agent = await createAgent({ + name: 'paid-catalog', + version: '1.0.0', +}) + .use(http()) + .use(payments({ config: paymentsFromEnv() })) + .use(catalog({ + file: './products.yaml', + handlerFactory: (item) => async ({ input }) => ({ + output: { product: item.name, data: 'processed' }, + }), + })) + .build(); +``` + +### With MPP + +Combine with the MPP extension for multi-method payments (Tempo, Stripe, Lightning): + +```typescript +import { mpp, tempo } from '@lucid-agents/mpp'; +import { catalog } from '@lucid-agents/catalog'; + +const agent = await createAgent({ + name: 'mpp-catalog', + version: '1.0.0', +}) + .use(http()) + .use(mpp({ + config: { + methods: [tempo.server({ currency: '0x...', recipient: '0x...' })], + currency: 'usd', + }, + })) + .use(catalog({ + file: './products.yaml', + keyPrefix: 'store/', + handlerFactory: (item) => async ({ input }) => ({ + output: { product: item.key, price: item.price }, + }), + })) + .build(); +``` + +Both x402 and MPP can coexist on the same agent alongside catalog. + +## Runtime access + +After building, the catalog items are available on the runtime for introspection: + +```typescript +const agent = await createAgent({ ... }) + .use(catalog({ file: './products.yaml' })) + .build(); + +// Access parsed catalog items +const items = (agent as any).catalog.items; +console.log(`Loaded ${items.length} products`); + +for (const item of items) { + console.log(`${item.key}: ${item.name} — $${item.price ?? 'free'}`); +} +``` + +## API reference + +### parseCatalogYaml(content) + +Parses a YAML string into validated `CatalogItem[]`. Accepts either a top-level array or `{ products: [...] }` wrapper. + +```typescript +import { parseCatalogYaml } from '@lucid-agents/catalog'; + +const items = parseCatalogYaml(yamlString); +``` + +Throws if any item fails validation against `CatalogItemSchema`. + +### parseCatalogCsv(content) + +Parses a CSV string into validated `CatalogItem[]`. Requires a `key` column. Columns prefixed with `meta_` are collected into `metadata`. + +```typescript +import { parseCatalogCsv } from '@lucid-agents/catalog'; + +const items = parseCatalogCsv(csvString); +``` + +### generateEntrypoints(items, options?) + +Converts `CatalogItem[]` into `EntrypointDef[]` ready for registration: + +```typescript +import { generateEntrypoints } from '@lucid-agents/catalog'; + +const entrypoints = generateEntrypoints(items, { + keyPrefix: 'api/', + network: 'eip155:8453', + handlerFactory: (item) => async ({ input }) => ({ + output: { result: item.name }, + }), +}); +``` + +### CatalogItemSchema + +Zod schema for validating catalog items. Useful for custom parsing pipelines: + +```typescript +import { CatalogItemSchema } from '@lucid-agents/catalog'; + +const result = CatalogItemSchema.safeParse({ + key: 'test', + name: 'Test Item', + price: '0.01', +}); +``` + +## Network support + +Network identifiers use [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) format: + +| Network | CAIP-2 ID | +|---------|-----------| +| Base | `eip155:8453` | +| Base Sepolia | `eip155:84532` | +| Ethereum | `eip155:1` | +| Solana Mainnet | `solana:mainnet` | +| Solana Devnet | `solana:devnet` | + +Set a default via the `network` option, or override per item in your catalog file. Item-level network always takes precedence over the global default. + +## Exports + +```typescript +// Extension +export { catalog } from '@lucid-agents/catalog'; + +// Parsers +export { parseCatalogYaml, parseCatalogCsv } from '@lucid-agents/catalog'; + +// Entrypoint generation +export { generateEntrypoints } from '@lucid-agents/catalog'; + +// Schema & types +export { + CatalogItemSchema, + type CatalogItem, + type CatalogConfig, + type CatalogExtensionOptions, +} from '@lucid-agents/catalog'; +``` + +## Related + +- [Payments Extension](/docs/packages/payments) — x402 payment protocol (USDC) +- [MPP Extension](/docs/packages/mpp) — Machine Payments Protocol (multi-method) +- [HTTP Extension](/docs/packages/http) — HTTP request/response handling +- [Core](/docs/packages/core) — Agent runtime and extension system diff --git a/lucid-docs/content/docs/packages/index.mdx b/lucid-docs/content/docs/packages/index.mdx index 8284100c..c2a0d285 100644 --- a/lucid-docs/content/docs/packages/index.mdx +++ b/lucid-docs/content/docs/packages/index.mdx @@ -53,6 +53,11 @@ Optional capabilities you can add to your agent: description="Machine Payments Protocol — multi-method payments via HTTP 402" href="/docs/packages/mpp" /> + async ({ input }) => { + return { output: { product: item.key } }; + }, +})); +``` + +**`CatalogExtensionOptions`** + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `file` | `string` | Yes | Path to the `.yaml`, `.yml`, or `.csv` catalog file. | +| `keyPrefix` | `string` | No | Prefix prepended to every item key (e.g., `"store/"` turns `"widget"` into `"store/widget"`). | +| `network` | `string` | No | Default CAIP-2 network for items that do not specify their own. | +| `handlerFactory` | `HandlerFactory` | No | Function that receives a `CatalogItem` and returns a handler function. | +| `inputSchema` | `z.ZodTypeAny` | No | Zod schema applied to all generated entrypoints. Defaults to `z.object({ params: z.record(z.string(), z.unknown()).optional() })`. | + +### `parseCatalogYaml(content)` + +Parses a YAML string into an array of validated `CatalogItem` objects. + +```typescript +import { parseCatalogYaml } from '@lucid-agents/catalog'; + +const items = parseCatalogYaml(` +products: + - key: weather + name: Weather Lookup + price: "0.001" +`); +// items: CatalogItem[] +``` + +Accepts either a top-level array or an object with a `products` key. Throws if any item fails validation. + +### `parseCatalogCsv(content)` + +Parses a CSV string into an array of validated `CatalogItem` objects. + +```typescript +import { parseCatalogCsv } from '@lucid-agents/catalog'; + +const items = parseCatalogCsv(`key,name,price +weather,Weather Lookup,0.001 +translate,Translation,0.002`); +// items: CatalogItem[] +``` + +Requires a `key` column. Columns prefixed with `meta_` are collected into the `metadata` field. Throws if any row fails validation. + +### `generateEntrypoints(items, options?)` + +Converts an array of `CatalogItem` objects into `EntrypointDef` objects ready for registration. + +```typescript +import { generateEntrypoints } from '@lucid-agents/catalog'; + +const entrypoints = generateEntrypoints(items, { + keyPrefix: 'api/', + network: 'eip155:8453', + handlerFactory: (item) => async ({ input }) => { + return { output: { result: item.name } }; + }, +}); + +for (const ep of entrypoints) { + runtime.entrypoints.add(ep); +} +``` + +**`GenerateOptions`** + +| Option | Type | Description | +|--------|------|-------------| +| `keyPrefix` | `string` | Prefix prepended to each item key. | +| `network` | `string` | Default network for items without one. | +| `handlerFactory` | `HandlerFactory` | Generates a handler for each item. | +| `inputSchema` | `z.ZodTypeAny` | Input schema for all entrypoints. | + +### `CatalogItemSchema` + +Zod schema used to validate catalog items. Useful for custom parsing or validation pipelines. + +```typescript +import { CatalogItemSchema } from '@lucid-agents/catalog'; + +const result = CatalogItemSchema.safeParse({ + key: 'test', + name: 'Test Item', + price: '0.01', +}); +``` + +### `CatalogItem` + +TypeScript type inferred from `CatalogItemSchema`: + +```typescript +type CatalogItem = { + key: string; + name: string; + description?: string; + price?: string | { invoke?: string; stream?: string }; + network?: string; + metadata?: Record; +}; +``` + +## Integration Examples + +### With x402 Payments + +Combine catalog routes with x402 payment gating: + +```typescript +import { createAgent } from '@lucid-agents/core'; +import { http } from '@lucid-agents/http'; +import { payments, paymentsFromEnv } from '@lucid-agents/payments'; +import { catalog } from '@lucid-agents/catalog'; +import { createAgentApp } from '@lucid-agents/hono'; + +const agent = await createAgent({ + name: 'paid-catalog-agent', + version: '1.0.0', + description: 'Catalog agent with x402 payments', +}) + .use(http()) + .use(payments({ config: paymentsFromEnv() })) + .use(catalog({ + file: './products.yaml', + handlerFactory: (item) => async ({ input }) => { + return { output: { product: item.name, data: 'processed' } }; + }, + })) + .build(); + +const { app } = await createAgentApp(agent); +export default app; +``` + +Each catalog item with a `price` field will automatically require x402 payment. + +### With MPP (Machine Payments Protocol) + +```typescript +import { createAgent } from '@lucid-agents/core'; +import { http } from '@lucid-agents/http'; +import { mpp, tempo } from '@lucid-agents/mpp'; +import { catalog } from '@lucid-agents/catalog'; +import { createAgentApp } from '@lucid-agents/hono'; + +const agent = await createAgent({ + name: 'mpp-catalog-agent', + version: '1.0.0', + description: 'Catalog agent with MPP payments', +}) + .use(http()) + .use(mpp({ + config: { + methods: [tempo.server({ currency: '0x...', recipient: '0x...' })], + currency: 'usd', + }, + })) + .use(catalog({ + file: './services.yaml', + network: 'eip155:8453', + handlerFactory: (item) => async ({ input }) => { + return { output: { service: item.name } }; + }, + })) + .build(); + +const { app } = await createAgentApp(agent); +export default app; +``` + +See `packages/examples/src/catalog/catalog-mpp-store.ts` for a full working example. + +### Custom Handler Factory + +The handler factory receives the full `CatalogItem`, giving you access to metadata for dynamic behavior: + +```typescript +import type { CatalogItem } from '@lucid-agents/catalog'; + +const handlerFactory = (item: CatalogItem) => async ({ input }: any) => { + const model = item.metadata?.model as string ?? 'gpt-4o-mini'; + const category = item.metadata?.category as string ?? 'general'; + + // Route to different backends based on metadata + const result = await processWithModel(model, category, input); + + return { output: result }; +}; + +agent.use(catalog({ + file: './products.yaml', + handlerFactory, +})); +``` + +### Key Prefix for Namespacing + +Use `keyPrefix` to namespace catalog routes, avoiding collisions when loading multiple catalogs: + +```typescript +const agent = await createAgent({ + name: 'multi-catalog-agent', + version: '1.0.0', +}) + .use(http()) + .use(catalog({ file: './nlp-services.yaml', keyPrefix: 'nlp/' })) + .use(catalog({ file: './vision-services.csv', keyPrefix: 'vision/' })) + .build(); +``` + +This produces routes like `nlp/sentiment`, `nlp/translate`, `vision/classify`, etc. + +## Network Support + +Network identifiers follow the [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format. Common values: + +| Network | CAIP-2 ID | +|---------|-----------| +| Base | `eip155:8453` | +| Base Sepolia | `eip155:84532` | +| Ethereum | `eip155:1` | +| Solana Mainnet | `solana:mainnet` | +| Solana Devnet | `solana:devnet` | + +Set a default network for all items via the `network` option, or override per item in your catalog file. + +## Related Packages + +| Package | Description | +|---------|-------------| +| [`@lucid-agents/core`](../core) | Core runtime with extension system and `createAgent()` | +| [`@lucid-agents/http`](../http) | HTTP extension for request handling | +| [`@lucid-agents/payments`](../payments) | x402 payment tracking and policy enforcement | +| [`@lucid-agents/mpp`](../mpp) | Machine Payments Protocol integration | +| [`@lucid-agents/hono`](../hono) | Hono adapter for serving agents over HTTP | diff --git a/packages/catalog/package.json b/packages/catalog/package.json new file mode 100644 index 00000000..405edfaa --- /dev/null +++ b/packages/catalog/package.json @@ -0,0 +1,45 @@ +{ + "name": "@lucid-agents/catalog", + "version": "0.1.0", + "description": "YAML/CSV catalog-driven route generation for Lucid Agents", + "repository": { + "type": "git", + "url": "https://github.com/daydreamsai/lucid-agents", + "directory": "packages/catalog" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": ["dist"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "type-check": "tsc -p tsconfig.json --noEmit", + "test": "bun test" + }, + "dependencies": { + "@lucid-agents/types": "workspace:*", + "yaml": "^2.7.1", + "csv-parse": "^5.6.0", + "zod": "catalog:" + }, + "devDependencies": { + "tsup": "catalog:", + "typescript": "catalog:", + "@lucid-agents/core": "workspace:*", + "@lucid-agents/http": "workspace:*" + } +} diff --git a/packages/catalog/src/__tests__/catalog.test.ts b/packages/catalog/src/__tests__/catalog.test.ts new file mode 100644 index 00000000..6721c976 --- /dev/null +++ b/packages/catalog/src/__tests__/catalog.test.ts @@ -0,0 +1,471 @@ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { z } from 'zod'; + +// Types we'll implement +import { + parseCatalogYaml, + parseCatalogCsv, + type CatalogItem, + type CatalogConfig, + CatalogItemSchema, + generateEntrypoints, + catalog, +} from '../index'; + +describe('CatalogItemSchema', () => { + it('validates a complete catalog item', () => { + const item = { + key: 'product-1', + name: 'Premium API Call', + description: 'Get premium data', + price: '0.50', + }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(true); + }); + + it('validates item with invoke/stream pricing', () => { + const item = { + key: 'product-2', + name: 'Streaming API', + description: 'Stream data', + price: { invoke: '1.00', stream: '0.10' }, + }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(true); + }); + + it('validates item with metadata', () => { + const item = { + key: 'product-3', + name: 'Custom Product', + description: 'A product', + price: '2.00', + metadata: { category: 'ai', tier: 'premium' }, + }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(true); + }); + + it('rejects item without key', () => { + const item = { name: 'No Key', price: '1.00' }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(false); + }); + + it('rejects item without name', () => { + const item = { key: 'no-name', price: '1.00' }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(false); + }); + + it('allows item without price (free)', () => { + const item = { key: 'free-item', name: 'Free Thing', description: 'Free' }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(true); + }); + + it('validates item with network field', () => { + const item = { + key: 'net-item', + name: 'Network Product', + price: '1.00', + network: 'eip155:84532', + }; + const result = CatalogItemSchema.safeParse(item); + expect(result.success).toBe(true); + }); +}); + +describe('parseCatalogYaml', () => { + it('parses a simple YAML catalog', () => { + const yamlContent = ` +products: + - key: widget-a + name: Widget A + description: A fine widget + price: "1.00" + - key: widget-b + name: Widget B + description: Another widget + price: "2.50" +`; + const items = parseCatalogYaml(yamlContent); + expect(items).toHaveLength(2); + expect(items[0].key).toBe('widget-a'); + expect(items[0].name).toBe('Widget A'); + expect(items[0].price).toBe('1.00'); + expect(items[1].key).toBe('widget-b'); + expect(items[1].price).toBe('2.50'); + }); + + it('parses YAML with invoke/stream pricing', () => { + const yamlContent = ` +products: + - key: streaming-api + name: Streaming API + description: Streamed data + price: + invoke: "5.00" + stream: "0.50" +`; + const items = parseCatalogYaml(yamlContent); + expect(items).toHaveLength(1); + expect(items[0].price).toEqual({ invoke: '5.00', stream: '0.50' }); + }); + + it('parses YAML with metadata', () => { + const yamlContent = ` +products: + - key: premium + name: Premium + description: Premium tier + price: "10.00" + metadata: + tier: premium + rateLimit: 1000 +`; + const items = parseCatalogYaml(yamlContent); + expect(items[0].metadata).toEqual({ tier: 'premium', rateLimit: 1000 }); + }); + + it('parses YAML with network field', () => { + const yamlContent = ` +products: + - key: solana-product + name: Solana Product + price: "1.00" + network: solana:devnet +`; + const items = parseCatalogYaml(yamlContent); + expect(items[0].network).toBe('solana:devnet'); + }); + + it('supports flat array format (no products wrapper)', () => { + const yamlContent = ` +- key: item-1 + name: Item 1 + price: "1.00" +- key: item-2 + name: Item 2 + price: "2.00" +`; + const items = parseCatalogYaml(yamlContent); + expect(items).toHaveLength(2); + }); + + it('throws on invalid YAML structure', () => { + expect(() => parseCatalogYaml('not: valid: yaml: [')).toThrow(); + }); + + it('throws on items missing required fields', () => { + const yamlContent = ` +products: + - description: no key or name + price: "1.00" +`; + expect(() => parseCatalogYaml(yamlContent)).toThrow(); + }); +}); + +describe('parseCatalogCsv', () => { + it('parses a simple CSV catalog', () => { + const csvContent = `key,name,description,price +widget-a,Widget A,A fine widget,1.00 +widget-b,Widget B,Another widget,2.50`; + const items = parseCatalogCsv(csvContent); + expect(items).toHaveLength(2); + expect(items[0].key).toBe('widget-a'); + expect(items[0].name).toBe('Widget A'); + expect(items[0].price).toBe('1.00'); + }); + + it('handles quoted fields with commas', () => { + const csvContent = `key,name,description,price +api-1,"Advanced API","AI-powered, real-time data",3.00`; + const items = parseCatalogCsv(csvContent); + expect(items[0].description).toBe('AI-powered, real-time data'); + }); + + it('handles empty price as free', () => { + const csvContent = `key,name,description,price +free-item,Free Item,No cost,`; + const items = parseCatalogCsv(csvContent); + expect(items[0].price).toBeUndefined(); + }); + + it('parses CSV with network column', () => { + const csvContent = `key,name,description,price,network +sol-item,Sol Item,On Solana,1.00,solana:devnet`; + const items = parseCatalogCsv(csvContent); + expect(items[0].network).toBe('solana:devnet'); + }); + + it('parses CSV with metadata columns (prefixed with meta_)', () => { + const csvContent = `key,name,description,price,meta_category,meta_tier +premium,Premium,Top tier,10.00,ai,premium`; + const items = parseCatalogCsv(csvContent); + expect(items[0].metadata).toEqual({ category: 'ai', tier: 'premium' }); + }); + + it('throws on CSV missing key column', () => { + const csvContent = `name,description,price +Widget,A widget,1.00`; + expect(() => parseCatalogCsv(csvContent)).toThrow(); + }); +}); + +describe('generateEntrypoints', () => { + const sampleItems: CatalogItem[] = [ + { key: 'widget-a', name: 'Widget A', description: 'A widget', price: '1.00' }, + { key: 'widget-b', name: 'Widget B', description: 'B widget', price: '2.50' }, + ]; + + it('generates one entrypoint per catalog item', () => { + const entrypoints = generateEntrypoints(sampleItems); + expect(entrypoints).toHaveLength(2); + expect(entrypoints[0].key).toBe('widget-a'); + expect(entrypoints[1].key).toBe('widget-b'); + }); + + it('sets price on entrypoints', () => { + const entrypoints = generateEntrypoints(sampleItems); + expect(entrypoints[0].price).toBe('1.00'); + expect(entrypoints[1].price).toBe('2.50'); + }); + + it('sets description from catalog item', () => { + const entrypoints = generateEntrypoints(sampleItems); + expect(entrypoints[0].description).toBe('A widget'); + }); + + it('generates entrypoints with invoke/stream pricing', () => { + const items: CatalogItem[] = [ + { key: 'stream-api', name: 'Stream', price: { invoke: '5.00', stream: '0.50' } }, + ]; + const entrypoints = generateEntrypoints(items); + expect(entrypoints[0].price).toEqual({ invoke: '5.00', stream: '0.50' }); + }); + + it('generates entrypoints without price for free items', () => { + const items: CatalogItem[] = [ + { key: 'free-item', name: 'Free' }, + ]; + const entrypoints = generateEntrypoints(items); + expect(entrypoints[0].price).toBeUndefined(); + }); + + it('passes metadata through to entrypoint', () => { + const items: CatalogItem[] = [ + { key: 'meta-item', name: 'Meta', price: '1.00', metadata: { tier: 'gold' } }, + ]; + const entrypoints = generateEntrypoints(items); + expect(entrypoints[0].metadata).toEqual({ tier: 'gold', catalogItem: items[0] }); + }); + + it('applies custom handler factory when provided', () => { + const handlerFactory = (item: CatalogItem) => { + return async (ctx: any) => ({ output: { product: item.key } }); + }; + const entrypoints = generateEntrypoints(sampleItems, { handlerFactory }); + expect(entrypoints[0].handler).toBeDefined(); + }); + + it('applies key prefix when provided', () => { + const entrypoints = generateEntrypoints(sampleItems, { keyPrefix: 'shop/' }); + expect(entrypoints[0].key).toBe('shop/widget-a'); + expect(entrypoints[1].key).toBe('shop/widget-b'); + }); + + it('applies network override to all entrypoints', () => { + const entrypoints = generateEntrypoints(sampleItems, { network: 'eip155:84532' }); + expect(entrypoints[0].network).toBe('eip155:84532'); + expect(entrypoints[1].network).toBe('eip155:84532'); + }); + + it('item-level network overrides global network', () => { + const items: CatalogItem[] = [ + { key: 'item', name: 'Item', price: '1.00', network: 'solana:devnet' }, + ]; + const entrypoints = generateEntrypoints(items, { network: 'eip155:84532' }); + expect(entrypoints[0].network).toBe('solana:devnet'); + }); + + it('sets default input schema with product key', () => { + const entrypoints = generateEntrypoints(sampleItems); + // Should have a basic input schema + expect(entrypoints[0].input).toBeDefined(); + }); + + it('applies custom input schema when provided', () => { + const customInput = z.object({ quantity: z.number() }); + const entrypoints = generateEntrypoints(sampleItems, { inputSchema: customInput }); + expect(entrypoints[0].input).toBe(customInput); + }); +}); + +describe('catalog extension', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `catalog-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('creates a valid extension object', () => { + const yamlPath = join(tmpDir, 'products.yaml'); + writeFileSync(yamlPath, ` +products: + - key: test + name: Test + price: "1.00" +`); + const ext = catalog({ file: yamlPath }); + expect(ext.name).toBe('catalog'); + expect(ext.build).toBeDefined(); + }); + + it('build returns catalog runtime with items', () => { + const yamlPath = join(tmpDir, 'products.yaml'); + writeFileSync(yamlPath, ` +products: + - key: test-product + name: Test Product + price: "1.00" + - key: another + name: Another + price: "2.00" +`); + const ext = catalog({ file: yamlPath }); + const result = ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} }); + expect(result.catalog).toBeDefined(); + expect(result.catalog!.items).toHaveLength(2); + expect(result.catalog!.items[0].key).toBe('test-product'); + }); + + it('onBuild registers entrypoints from catalog', async () => { + const yamlPath = join(tmpDir, 'products.yaml'); + writeFileSync(yamlPath, ` +products: + - key: alpha + name: Alpha + description: Alpha product + price: "1.00" + - key: beta + name: Beta + description: Beta product + price: "2.00" +`); + const ext = catalog({ file: yamlPath }); + ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} }); + + // Simulate runtime with entrypoints.add + const added: any[] = []; + const mockRuntime = { + entrypoints: { + add: (def: any) => added.push(def), + list: () => [], + snapshot: () => [], + }, + } as any; + + await ext.onBuild!(mockRuntime); + expect(added).toHaveLength(2); + expect(added[0].key).toBe('alpha'); + expect(added[0].price).toBe('1.00'); + expect(added[1].key).toBe('beta'); + }); + + it('loads CSV files and populates catalog.items', async () => { + const csvPath = join(tmpDir, 'products.csv'); + writeFileSync(csvPath, `key,name,description,price +item-1,Item 1,First item,1.00 +item-2,Item 2,Second item,2.00`); + const ext = catalog({ file: csvPath }); + const result = ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} }); + + // CSV is now parsed synchronously — catalog.items should be populated immediately + expect(result.catalog!.items).toHaveLength(2); + expect(result.catalog!.items[0].key).toBe('item-1'); + + const added: any[] = []; + const mockRuntime = { + entrypoints: { + add: (def: any) => added.push(def), + list: () => [], + snapshot: () => [], + }, + } as any; + + await ext.onBuild!(mockRuntime); + expect(added).toHaveLength(2); + expect(added[0].key).toBe('item-1'); + }); + + it('applies handlerFactory from options', async () => { + const yamlPath = join(tmpDir, 'products.yaml'); + writeFileSync(yamlPath, ` +products: + - key: test + name: Test + price: "1.00" +`); + const handlerFactory = (item: CatalogItem) => { + return async () => ({ output: { product: item.key, price: item.price } }); + }; + const ext = catalog({ file: yamlPath, handlerFactory }); + ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} }); + + const added: any[] = []; + const mockRuntime = { + entrypoints: { + add: (def: any) => added.push(def), + list: () => [], + snapshot: () => [], + }, + } as any; + + await ext.onBuild!(mockRuntime); + expect(added[0].handler).toBeDefined(); + const result = await added[0].handler({}); + expect(result.output.product).toBe('test'); + }); + + it('applies keyPrefix from options', async () => { + const yamlPath = join(tmpDir, 'products.yaml'); + writeFileSync(yamlPath, ` +products: + - key: test + name: Test + price: "1.00" +`); + const ext = catalog({ file: yamlPath, keyPrefix: 'store/' }); + ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} }); + + const added: any[] = []; + const mockRuntime = { + entrypoints: { + add: (def: any) => added.push(def), + list: () => [], + snapshot: () => [], + }, + } as any; + + await ext.onBuild!(mockRuntime); + expect(added[0].key).toBe('store/test'); + }); + + it('throws if file does not exist', () => { + const ext = catalog({ file: '/nonexistent/path.yaml' }); + expect(() => ext.build({ meta: { name: 'test', version: '1.0.0' }, runtime: {} })).toThrow(); + }); +}); diff --git a/packages/catalog/src/entrypoints.ts b/packages/catalog/src/entrypoints.ts new file mode 100644 index 00000000..a5666af4 --- /dev/null +++ b/packages/catalog/src/entrypoints.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import type { EntrypointDef } from '@lucid-agents/types/core'; +import type { CatalogItem, HandlerFactory } from './types'; + +export type GenerateOptions = { + keyPrefix?: string; + network?: string; + handlerFactory?: HandlerFactory; + inputSchema?: z.ZodTypeAny; +}; + +const defaultInputSchema = z.object({ + params: z.record(z.string(), z.unknown()).optional(), +}); + +export function generateEntrypoints( + items: CatalogItem[], + options?: GenerateOptions, +): EntrypointDef[] { + const { keyPrefix, network, handlerFactory, inputSchema } = options ?? {}; + + return items.map((item): EntrypointDef => { + const key = keyPrefix ? `${keyPrefix}${item.key}` : item.key; + const entrypointNetwork = item.network ?? network; + + const metadata = { + ...item.metadata, + catalogItem: item, + }; + + const entrypoint: EntrypointDef = { + key, + description: item.description, + price: item.price as EntrypointDef['price'], + input: inputSchema ?? defaultInputSchema, + metadata, + ...(entrypointNetwork ? { network: entrypointNetwork as EntrypointDef['network'] } : {}), + }; + + if (handlerFactory) { + entrypoint.handler = handlerFactory(item) as EntrypointDef['handler']; + } + + return entrypoint; + }); +} diff --git a/packages/catalog/src/extension.ts b/packages/catalog/src/extension.ts new file mode 100644 index 00000000..fd8b011d --- /dev/null +++ b/packages/catalog/src/extension.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'fs'; +import { extname } from 'path'; +import type { + Extension, + BuildContext, + AgentRuntime, +} from '@lucid-agents/types/core'; +import type { CatalogItem, CatalogExtensionOptions } from './types'; +import { parseCatalogYaml, parseCatalogCsv } from './parser'; +import { generateEntrypoints } from './entrypoints'; + +export type CatalogRuntime = { + items: CatalogItem[]; +}; + +export function catalog( + options: CatalogExtensionOptions, +): Extension<{ catalog?: CatalogRuntime }> { + let catalogItems: CatalogItem[] = []; + + return { + name: 'catalog', + build(ctx: BuildContext): { catalog?: CatalogRuntime } { + const ext = extname(options.file).toLowerCase(); + const content = readFileSync(options.file, 'utf-8'); + + if (ext === '.yaml' || ext === '.yml') { + catalogItems = parseCatalogYaml(content); + } else if (ext === '.csv') { + catalogItems = parseCatalogCsv(content); + } else { + throw new Error( + `Unsupported catalog file format: ${ext}. Use .yaml, .yml, or .csv`, + ); + } + + return { + catalog: { + items: catalogItems, + }, + }; + }, + async onBuild(runtime: AgentRuntime): Promise { + const entrypoints = generateEntrypoints(catalogItems, { + keyPrefix: options.keyPrefix, + network: options.network, + handlerFactory: options.handlerFactory, + inputSchema: options.inputSchema, + }); + + for (const ep of entrypoints) { + runtime.entrypoints.add(ep); + } + }, + }; +} diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts new file mode 100644 index 00000000..26da9778 --- /dev/null +++ b/packages/catalog/src/index.ts @@ -0,0 +1,9 @@ +export { parseCatalogYaml, parseCatalogCsv } from './parser'; +export { generateEntrypoints } from './entrypoints'; +export { catalog } from './extension'; +export { + CatalogItemSchema, + type CatalogItem, + type CatalogConfig, + type CatalogExtensionOptions, +} from './types'; diff --git a/packages/catalog/src/parser.ts b/packages/catalog/src/parser.ts new file mode 100644 index 00000000..027b81ca --- /dev/null +++ b/packages/catalog/src/parser.ts @@ -0,0 +1,85 @@ +import YAML from 'yaml'; +import { parse as csvParse } from 'csv-parse/sync'; +import { CatalogItemSchema, type CatalogItem } from './types'; + +export function parseCatalogYaml(content: string): CatalogItem[] { + const parsed = YAML.parse(content); + + let rawItems: unknown[]; + + if (Array.isArray(parsed)) { + rawItems = parsed; + } else if ( + parsed && + typeof parsed === 'object' && + 'products' in parsed && + Array.isArray(parsed.products) + ) { + rawItems = parsed.products; + } else { + throw new Error( + 'YAML must contain a "products" array or be a top-level array', + ); + } + + const items: CatalogItem[] = []; + for (const raw of rawItems) { + const result = CatalogItemSchema.safeParse(raw); + if (!result.success) { + throw new Error(`Invalid catalog item: ${result.error.message}`); + } + items.push(result.data); + } + + return items; +} + +export function parseCatalogCsv( + content: string, +): CatalogItem[] { + const records = csvParse(content, { + columns: true, + skip_empty_lines: true, + trim: true, + }) as Record[]; + + // Validate key column exists + if (records.length > 0 && !('key' in records[0])) { + throw new Error('CSV must have a "key" column'); + } + + const items: CatalogItem[] = []; + for (const record of records) { + const metadata: Record = {}; + let hasMetadata = false; + + for (const [col, val] of Object.entries(record)) { + if (col.startsWith('meta_')) { + metadata[col.slice(5)] = val; + hasMetadata = true; + } + } + + const item: CatalogItem = { + key: record.key, + name: record.name, + description: record.description || undefined, + price: + record.price && record.price.trim() !== '' + ? record.price + : undefined, + network: record.network || undefined, + ...(hasMetadata ? { metadata } : {}), + }; + + const result = CatalogItemSchema.safeParse(item); + if (!result.success) { + throw new Error( + `Invalid CSV row for key "${record.key}": ${result.error.message}`, + ); + } + items.push(result.data); + } + + return items; +} diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts new file mode 100644 index 00000000..e8aabbee --- /dev/null +++ b/packages/catalog/src/types.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const CatalogItemSchema = z.object({ + key: z.string(), + name: z.string(), + description: z.string().optional(), + price: z + .union([ + z.string(), + z.object({ + invoke: z.string().optional(), + stream: z.string().optional(), + }), + ]) + .optional(), + network: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type CatalogItem = z.infer; + +export type CatalogConfig = { + items: CatalogItem[]; +}; + +export type HandlerFactory = (item: CatalogItem) => (...args: any[]) => any; + +export type CatalogExtensionOptions = { + file: string; + keyPrefix?: string; + network?: string; + handlerFactory?: HandlerFactory; + inputSchema?: z.ZodTypeAny; +}; diff --git a/packages/catalog/tsconfig.build.json b/packages/catalog/tsconfig.build.json new file mode 100644 index 00000000..39b29485 --- /dev/null +++ b/packages/catalog/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.build.base.json", + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts", "**/__tests__/**"] +} diff --git a/packages/catalog/tsconfig.json b/packages/catalog/tsconfig.json new file mode 100644 index 00000000..a6098995 --- /dev/null +++ b/packages/catalog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*"] +} diff --git a/packages/catalog/tsup.config.ts b/packages/catalog/tsup.config.ts new file mode 100644 index 00000000..92f8226b --- /dev/null +++ b/packages/catalog/tsup.config.ts @@ -0,0 +1,13 @@ +import { definePackageConfig } from '../tsup.config.base'; + +export default definePackageConfig({ + entry: ['src/index.ts'], + dts: true, + external: [ + '@lucid-agents/core', + '@lucid-agents/types', + 'yaml', + 'csv-parse', + 'zod', + ], +}); diff --git a/packages/examples/package.json b/packages/examples/package.json index 942c26f7..1825ab49 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -18,6 +18,7 @@ "@lucid-agents/a2a": "workspace:*", "@lucid-agents/analytics": "workspace:*", "@lucid-agents/ap2": "workspace:*", + "@lucid-agents/catalog": "workspace:*", "@lucid-agents/core": "workspace:*", "@lucid-agents/hono": "workspace:*", "@lucid-agents/http": "workspace:*", diff --git a/packages/examples/src/catalog/catalog-mpp-store.ts b/packages/examples/src/catalog/catalog-mpp-store.ts new file mode 100644 index 00000000..adb04d6f --- /dev/null +++ b/packages/examples/src/catalog/catalog-mpp-store.ts @@ -0,0 +1,129 @@ +import { catalog, type CatalogItem } from '@lucid-agents/catalog'; +import { createAgent } from '@lucid-agents/core'; +import { createAgentApp } from '@lucid-agents/hono'; +import { http } from '@lucid-agents/http'; +import { mpp, tempo } from '@lucid-agents/mpp'; +import { join } from 'path'; + +/** + * Catalog + MPP Store Agent + * + * Demonstrates how to generate hundreds of paid entrypoint routes from a single + * YAML catalog file, with payments enforced via Machine Payments Protocol (MPP). + * + * Instead of writing 10+ addEntrypoint() calls manually, the catalog extension + * reads products.yaml and auto-registers each product as a paid route. + * + * Run: bun run packages/examples/src/catalog/catalog-mpp-store.ts + * + * Environment variables: + * MPP_TEMPO_CURRENCY - Token address (default: pathUSD contract) + * MPP_TEMPO_RECIPIENT - Recipient wallet address (default: dev wallet) + * PORT - Server port (default: 3000) + */ + +// ─── Handler Factory ───────────────────────────────────────────── +// Each catalog item gets its own handler via this factory. +// In production, you'd route to actual AI models or services. + +const handlerFactory = (item: CatalogItem) => { + return async (ctx: { input: { params?: Record } }) => { + return { + output: { + product: item.key, + name: item.name, + description: item.description ?? 'No description', + price: item.price ?? 'free', + metadata: item.metadata ?? {}, + params: ctx.input.params ?? {}, + timestamp: new Date().toISOString(), + }, + }; + }; +}; + +// ─── Agent Setup ───────────────────────────────────────────────── + +const catalogFile = join(import.meta.dir, 'products.yaml'); + +const agent = await createAgent({ + name: 'catalog-mpp-store', + version: '1.0.0', + description: 'AI service marketplace powered by YAML catalog + MPP payments', +}) + .use(http()) + .use( + mpp({ + config: { + methods: [ + tempo.server({ + currency: + process.env.MPP_TEMPO_CURRENCY ?? + '0x20c0000000000000000000000000000000000000', // pathUSD + recipient: + process.env.MPP_TEMPO_RECIPIENT ?? + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // dev wallet + }), + ], + currency: 'usd', + defaultIntent: 'charge', + }, + }) + ) + .use( + catalog({ + file: catalogFile, + keyPrefix: 'store/', + handlerFactory, + }) + ) + .build(); + +// ─── Access Catalog at Runtime ─────────────────────────────────── +// The catalog runtime exposes parsed items for introspection. + +const items: CatalogItem[] = + (agent as unknown as { catalog?: { items: CatalogItem[] } }).catalog?.items ?? + []; +console.log(`\nLoaded ${items.length} products from catalog\n`); + +// ─── Create Hono App ───────────────────────────────────────────── + +const { app } = await createAgentApp(agent); + +// ─── Start Server ──────────────────────────────────────────────── + +const port = Number(process.env.PORT ?? 3000); + +const server = Bun.serve({ + port, + fetch: app.fetch, +}); + +console.log( + `Catalog MPP Store ready at http://${server.hostname}:${server.port}\n` +); +console.log('Endpoints:'); +console.log(' GET / -> Landing page'); +console.log(' GET /.well-known/agent.json -> Agent manifest'); +console.log(''); + +// List all catalog-generated routes with prices +for (const item of items) { + const key = `store/${item.key}`; + const price = + typeof item.price === 'object' + ? `invoke: $${item.price.invoke ?? '0'}, stream: $${item.price.stream ?? '0'}` + : item.price + ? `$${item.price}` + : 'free'; + const tier = item.metadata?.tier ?? '-'; + console.log( + ` POST /entrypoints/${key}/invoke -> ${item.name} (${price}) [${tier}]` + ); +} + +console.log( + '\nPayment: Machine Payments Protocol (HTTP 402 + WWW-Authenticate)' +); +console.log('Methods: tempo (stablecoin)\n'); diff --git a/packages/examples/src/catalog/products.csv b/packages/examples/src/catalog/products.csv new file mode 100644 index 00000000..5f7d12cf --- /dev/null +++ b/packages/examples/src/catalog/products.csv @@ -0,0 +1,6 @@ +key,name,description,price,network,meta_tier,meta_category,meta_rateLimit +ocr,OCR Scan,Extract text from images using OCR,0.50,,standard,vision,200 +embed,Text Embedding,Generate vector embeddings for text,0.02,,basic,ml,2000 +classify,Text Classifier,Classify text into categories,0.10,,basic,nlp,500 +rag-query,RAG Query,Retrieval-augmented generation query,1.00,,premium,search,100 +sol-verify,Solana Verify,Verify Solana transaction signatures,0.05,solana:devnet,standard,blockchain,1000 diff --git a/packages/examples/src/catalog/products.yaml b/packages/examples/src/catalog/products.yaml new file mode 100644 index 00000000..32fdcb2a --- /dev/null +++ b/packages/examples/src/catalog/products.yaml @@ -0,0 +1,106 @@ +# AI Service Product Catalog +# +# Each product becomes an entrypoint route on the agent. +# Prices are in USD. Payment is enforced via MPP (HTTP 402). +# +# Supported fields: +# key - unique route identifier (required) +# name - display name (required) +# description - human-readable description +# price - flat string ("1.00") or { invoke, stream } object +# network - CAIP-2 network identifier +# metadata - arbitrary key-value pairs + +products: + # ── Free tier ──────────────────────────────────────────────────── + - key: health + name: Health Check + description: Free health check endpoint + metadata: + tier: free + category: system + + # ── Basic tier ($0.01 - $0.10) ────────────────────────────────── + - key: text-stats + name: Text Statistics + description: Word count, character count, and reading time estimate + price: '0.01' + metadata: + tier: basic + category: text + rateLimit: 1000 + + - key: sentiment + name: Sentiment Analysis + description: Analyze text sentiment (positive, negative, neutral) + price: '0.05' + metadata: + tier: basic + category: nlp + rateLimit: 500 + + - key: translate + name: Quick Translate + description: Translate short text between languages + price: '0.10' + metadata: + tier: basic + category: nlp + rateLimit: 200 + + # ── Standard tier ($0.25 - $1.00) ────────────────────────────── + - key: summarize + name: Text Summarizer + description: Summarize long-form content into key points + price: + invoke: '0.50' + stream: '0.10' + metadata: + tier: standard + category: text + + - key: code-review + name: Code Review + description: Automated code review with suggestions + price: '1.00' + metadata: + tier: standard + category: code + rateLimit: 100 + + # ── Premium tier ($2.00 - $5.00) ──────────────────────────────── + - key: image-describe + name: Image Description + description: Generate detailed descriptions of images + price: '2.00' + metadata: + tier: premium + category: vision + + - key: document-extract + name: Document Extraction + description: Extract structured data from PDFs and documents + price: '3.00' + metadata: + tier: premium + category: extraction + + - key: generate-report + name: Report Generator + description: Generate comprehensive analysis reports + price: + invoke: '5.00' + stream: '1.00' + metadata: + tier: premium + category: analysis + + # ── Cross-chain product ───────────────────────────────────────── + - key: solana-nft-metadata + name: Solana NFT Metadata + description: Fetch and analyze Solana NFT metadata + price: '0.25' + network: 'solana:devnet' + metadata: + tier: standard + category: blockchain