diff --git a/packages/plugins/official/content-generator/__tests__/index.test.ts b/packages/plugins/official/content-generator/__tests__/index.test.ts new file mode 100644 index 0000000..cb6833b --- /dev/null +++ b/packages/plugins/official/content-generator/__tests__/index.test.ts @@ -0,0 +1,784 @@ +/// +/** + * Content Generator — Unit Tests + */ +import plugin, { + BUILT_IN_TEMPLATES, + DEFAULT_MODEL, + DEFAULT_TEMPERATURE, + ContentTemplate, + BatchJobRecord, + applyTemplate, + buildTemplateKey, + buildJobKey, + generateJobId, + analyzeKeywordDensity, + buildSeoSuffix, + AI_COMPLETIONS_PATH, + SUPPORTED_MODELS, +} from "../src/index"; +import { + PluginAPI, + PluginContext, + PluginDatabaseAPI, + PluginEventBus, + EndpointDefinition, + EndpointRequest, +} from "@agentbase/plugin-sdk"; + +// ── Mock factory ───────────────────────────────────────────────────────────── + +function makeAiResponse(content: string) { + return { + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content } }] }), + }; +} + +function createMockAPI( + aiContent = "Generated content", +): PluginAPI & { _endpoints: EndpointDefinition[] } { + const store = new Map(); + const _endpoints: EndpointDefinition[] = []; + + const db: PluginDatabaseAPI = { + set: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => store.set(k, v)), + get: jest + .fn() + .mockImplementation(async (k: string) => store.get(k) ?? null), + delete: jest.fn().mockImplementation(async (k: string) => { + const had = store.has(k); + store.delete(k); + return had; + }), + keys: jest + .fn() + .mockImplementation(async (prefix?: string) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + + const events: PluginEventBus = { + emit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + }; + + const configStore = new Map(); + + const api = { + _endpoints, + getConfig: jest + .fn() + .mockImplementation((key: string) => configStore.get(key) ?? undefined), + setConfig: jest + .fn() + .mockImplementation(async (key: string, value: unknown) => + configStore.set(key, value), + ), + makeRequest: jest.fn().mockResolvedValue(makeAiResponse(aiContent)), + log: jest.fn(), + db, + events, + registerEndpoint: jest + .fn() + .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)), + registerCronJob: jest.fn(), + registerWebhook: jest.fn(), + registerAdminPage: jest.fn(), + } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] }; + + return api; +} + +function makeCtx( + overrides: Partial = {}, + appConfig: Record = {}, +): PluginContext & { api: ReturnType } { + const api = createMockAPI(); + // Pre-populate configStore via setConfig mock + for (const [k, v] of Object.entries(appConfig)) { + (api.setConfig as jest.Mock)(k, v); + } + return { + appId: "app1", + userId: "user1", + config: appConfig, + api, + ...overrides, + } as unknown as PluginContext & { api: ReturnType }; +} + +async function initPlugin(ctx: ReturnType) { + await plugin.definition.hooks!["app:init"]!(ctx); + return ctx.api._endpoints; +} + +function makeRes() { + const res = { + _status: 200, + _data: undefined as unknown, + status(code: number) { + this._status = code; + return this; + }, + json(data: unknown) { + this._data = data; + }, + send(data: string) { + this._data = data; + }, + }; + return res; +} + +function fakeReq(override: Partial = {}): EndpointRequest { + return { + method: "GET", + path: "/", + params: {}, + query: {}, + body: {}, + headers: {}, + ...override, + }; +} + +function getEndpoint( + endpoints: EndpointDefinition[], + method: string, + path: string, +): EndpointDefinition { + const ep = endpoints.find((e) => e.method === method && e.path === path); + if (!ep) throw new Error(`Endpoint ${method} ${path} not found`); + return ep; +} + +// ── Helper function tests ───────────────────────────────────────────────────── + +describe("buildTemplateKey", () => { + it("prefixes with template:", () => { + expect(buildTemplateKey("blog-post")).toBe("template:blog-post"); + }); +}); + +describe("buildJobKey", () => { + it("prefixes with generated:", () => { + expect(buildJobKey("abc123")).toBe("generated:abc123"); + }); +}); + +describe("generateJobId", () => { + it("returns a non-empty string", () => { + expect(typeof generateJobId()).toBe("string"); + expect(generateJobId().length).toBeGreaterThan(0); + }); + + it("generates unique values", () => { + const ids = new Set(Array.from({ length: 50 }, generateJobId)); + expect(ids.size).toBe(50); + }); +}); + +describe("applyTemplate", () => { + it("replaces known variables", () => { + const result = applyTemplate("Hello {{name}}, welcome to {{place}}!", { + name: "Alice", + place: "Agentbase", + }); + expect(result).toBe("Hello Alice, welcome to Agentbase!"); + }); + + it("leaves unknown placeholders intact", () => { + const result = applyTemplate("Hello {{name}}, {{unknown}}!", { + name: "Bob", + }); + expect(result).toBe("Hello Bob, {{unknown}}!"); + }); + + it("handles empty variables map", () => { + const result = applyTemplate("No vars here.", {}); + expect(result).toBe("No vars here."); + }); + + it("replaces multiple occurrences of the same variable", () => { + const result = applyTemplate("{{x}} + {{x}} = 2{{x}}", { x: "y" }); + expect(result).toBe("y + y = 2y"); + }); +}); + +describe("analyzeKeywordDensity", () => { + it("returns 0 for empty text", () => { + expect(analyzeKeywordDensity("", "keyword")).toBe(0); + }); + + it("returns 0 for empty keyword", () => { + expect(analyzeKeywordDensity("some text here", "")).toBe(0); + }); + + it("calculates correct density", () => { + // "cat cat dog" — 3 words, cat appears twice → density = 2/3 + const density = analyzeKeywordDensity("cat cat dog", "cat"); + expect(density).toBeCloseTo(2 / 3); + }); + + it("is case-insensitive", () => { + const density = analyzeKeywordDensity("Cat CAT cat dog", "cat"); + expect(density).toBeCloseTo(3 / 4); + }); + + it("returns 0 when keyword absent", () => { + expect(analyzeKeywordDensity("the quick brown fox", "elephant")).toBe(0); + }); +}); + +describe("buildSeoSuffix", () => { + it("returns generic SEO instruction when no keyword provided", () => { + const suffix = buildSeoSuffix(); + expect(suffix).toContain("Optimize this content for SEO"); + expect(suffix).not.toContain("density"); + }); + + it("includes keyword density guidance when keyword given", () => { + const suffix = buildSeoSuffix("agentbase"); + expect(suffix).toContain("agentbase"); + expect(suffix).toContain("1–2%"); + }); + + it("starts with a newline", () => { + expect(buildSeoSuffix()).toMatch(/^\n/); + }); +}); + +// ── Template library ────────────────────────────────────────────────────────── + +describe("BUILT_IN_TEMPLATES", () => { + it("contains at least 50 templates", () => { + expect(BUILT_IN_TEMPLATES.length).toBeGreaterThanOrEqual(50); + }); + + it("every template has required fields", () => { + for (const tpl of BUILT_IN_TEMPLATES) { + expect(typeof tpl.slug).toBe("string"); + expect(typeof tpl.name).toBe("string"); + expect(typeof tpl.category).toBe("string"); + expect(typeof tpl.prompt).toBe("string"); + expect(Array.isArray(tpl.variables)).toBe(true); + expect(tpl.builtin).toBe(true); + } + }); + + it("all slugs are unique", () => { + const slugs = BUILT_IN_TEMPLATES.map((t) => t.slug); + const unique = new Set(slugs); + expect(unique.size).toBe(slugs.length); + }); + + it("BUILT_IN_TEMPLATES covers expected categories", () => { + const categories = new Set(BUILT_IN_TEMPLATES.map((t) => t.category)); + expect(categories.has("Blog & Content")).toBe(true); + expect(categories.has("Email Marketing")).toBe(true); + expect(categories.has("Social Media")).toBe(true); + expect(categories.has("SEO & Web")).toBe(true); + expect(categories.has("Business")).toBe(true); + }); + + it("every prompt references at least one variable listed in variables array", () => { + for (const tpl of BUILT_IN_TEMPLATES) { + // every declared variable should appear in the prompt + for (const v of tpl.variables) { + expect(tpl.prompt).toContain(`{{${v}}}`); + } + } + }); +}); + +// ── Plugin definition ───────────────────────────────────────────────────────── + +describe("plugin definition", () => { + it("has correct name and version", () => { + expect(plugin.definition.name).toBe("content-generator"); + expect(plugin.definition.version).toBe("1.0.0"); + }); + + it("defines three settings with correct types", () => { + const s = plugin.definition.settings!; + expect(s["defaultModel"]!.type).toBe("select"); + expect(s["defaultModel"]!.options).toEqual([...SUPPORTED_MODELS]); + expect(s["defaultModel"]!.default).toBe(DEFAULT_MODEL); + + expect(s["temperature"]!.type).toBe("number"); + expect(s["temperature"]!.default).toBe(DEFAULT_TEMPERATURE); + + expect(s["seoMode"]!.type).toBe("boolean"); + expect(s["seoMode"]!.default).toBe(false); + }); + + it("declares app:init hook", () => { + expect(typeof plugin.definition.hooks!["app:init"]).toBe("function"); + }); + + it("declares prompt:modify filter", () => { + expect(typeof plugin.definition.filters!["prompt:modify"]).toBe("function"); + }); +}); + +// ── app:init — endpoint registration and template seeding ──────────────────── + +describe("app:init", () => { + it("registers 5 endpoints", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + expect(eps).toHaveLength(5); + }); + + it("registers endpoints with correct methods and paths", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const pairs = eps.map((e) => `${e.method} ${e.path}`); + expect(pairs).toContain("GET /templates"); + expect(pairs).toContain("GET /templates/:id"); + expect(pairs).toContain("POST /generate"); + expect(pairs).toContain("POST /batch"); + expect(pairs).toContain("GET /batch/:jobId"); + }); + + it("seeds all built-in templates to the DB", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + // Verify a few representative templates were seeded + const blogPost = await ctx.api.db.get("template:blog-post"); + expect(blogPost).toBeTruthy(); + const emailSub = await ctx.api.db.get("template:email-subject-line"); + expect(emailSub).toBeTruthy(); + }); + + it("does not overwrite existing templates on re-init", async () => { + const ctx = makeCtx(); + // Pre-seed a custom version of a template + const custom: ContentTemplate = { + slug: "blog-post", + name: "Custom Blog Post", + category: "Blog & Content", + description: "Custom", + prompt: "Custom prompt", + variables: [], + builtin: false, + }; + await ctx.api.db.set("template:blog-post", custom); + + await initPlugin(ctx); + + const after = (await ctx.api.db.get( + "template:blog-post", + )) as ContentTemplate; + expect(after.name).toBe("Custom Blog Post"); + }); +}); + +// ── GET /templates ──────────────────────────────────────────────────────────── + +describe("GET /templates", () => { + it("returns all seeded templates sorted by category:name", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "GET", "/templates"); + + const res = makeRes(); + await ep.handler(fakeReq(), res as never); + + const data = res._data as { templates: ContentTemplate[]; total: number }; + expect(Array.isArray(data.templates)).toBe(true); + expect(data.total).toBeGreaterThanOrEqual(50); + expect(data.total).toBe(data.templates.length); + }); +}); + +// ── GET /templates/:id ──────────────────────────────────────────────────────── + +describe("GET /templates/:id", () => { + it("returns the requested template", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "GET", "/templates/:id"); + + const res = makeRes(); + await ep.handler(fakeReq({ params: { id: "blog-post" } }), res as never); + + const data = res._data as { template: ContentTemplate }; + expect(data.template.slug).toBe("blog-post"); + }); + + it("returns 404 for unknown template", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "GET", "/templates/:id"); + + const res = makeRes(); + await ep.handler(fakeReq({ params: { id: "nonexistent" } }), res as never); + + expect(res._status).toBe(404); + }); +}); + +// ── POST /generate ──────────────────────────────────────────────────────────── + +describe("POST /generate", () => { + it("returns generated text for a valid request", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce( + makeAiResponse("Great blog post content"), + ); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { + templateSlug: "blog-post", + variables: { + title: "Test Title", + topic: "AI", + audience: "developers", + tone: "professional", + wordCount: "500", + }, + }, + }), + res as never, + ); + + const data = res._data as { text: string; templateSlug: string }; + expect(data.text).toBe("Great blog post content"); + expect(data.templateSlug).toBe("blog-post"); + }); + + it("returns 400 when templateSlug is missing", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + const res = makeRes(); + await ep.handler(fakeReq({ method: "POST", body: {} }), res as never); + + expect(res._status).toBe(400); + }); + + it("returns 404 for unknown template slug", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { templateSlug: "no-such-template", variables: {} }, + }), + res as never, + ); + + expect(res._status).toBe(404); + }); + + it("returns 502 when AI service fails", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + }); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { templateSlug: "blog-post", variables: {} }, + }), + res as never, + ); + + expect(res._status).toBe(502); + }); + + it("uses defaultModel from config when no model specified", async () => { + const ctx = makeCtx({}, { defaultModel: "claude-3-5-sonnet" }); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce( + makeAiResponse("response"), + ); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { templateSlug: "blog-post", variables: {} }, + }), + res as never, + ); + + const callBody = JSON.parse( + (ctx.api.makeRequest as jest.Mock).mock.calls[0][1].body as string, + ) as { model: string }; + expect(callBody.model).toBe("claude-3-5-sonnet"); + }); + + it("appends SEO suffix when seoMode is true", async () => { + const ctx = makeCtx({}, { seoMode: true }); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce( + makeAiResponse("seo content"), + ); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { + templateSlug: "blog-post", + variables: {}, + keyword: "agentbase", + }, + }), + res as never, + ); + + const callBody = JSON.parse( + (ctx.api.makeRequest as jest.Mock).mock.calls[0][1].body as string, + ) as { messages: Array<{ content: string }> }; + expect(callBody.messages[0]!.content).toContain("agentbase"); + }); + + it("calls AI_COMPLETIONS_PATH", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/generate"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce( + makeAiResponse("ok"), + ); + + await ep.handler( + fakeReq({ + method: "POST", + body: { templateSlug: "blog-post", variables: {} }, + }), + makeRes() as never, + ); + + expect((ctx.api.makeRequest as jest.Mock).mock.calls[0][0]).toBe( + AI_COMPLETIONS_PATH, + ); + }); +}); + +// ── POST /batch ─────────────────────────────────────────────────────────────── + +describe("POST /batch", () => { + it("returns a completed job record with results", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/batch"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValue( + makeAiResponse("Batch result"), + ); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { + jobs: [ + { templateSlug: "blog-post", variables: {} }, + { templateSlug: "linkedin-post", variables: {} }, + ], + }, + }), + res as never, + ); + + const data = res._data as BatchJobRecord; + expect(data.status).toBe("completed"); + expect(data.results).toHaveLength(2); + expect(data.results[0]!.text).toBe("Batch result"); + expect(data.jobId).toBeTruthy(); + }); + + it("records template-not-found error in results", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/batch"); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { jobs: [{ templateSlug: "no-such-one", variables: {} }] }, + }), + res as never, + ); + + const data = res._data as BatchJobRecord; + expect(data.results[0]!.error).toMatch(/Template not found/); + }); + + it("returns 400 when jobs array is empty", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/batch"); + + const res = makeRes(); + await ep.handler( + fakeReq({ method: "POST", body: { jobs: [] } }), + res as never, + ); + + expect(res._status).toBe(400); + }); + + it("returns 400 when jobs is missing", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/batch"); + + const res = makeRes(); + await ep.handler(fakeReq({ method: "POST", body: {} }), res as never); + + expect(res._status).toBe(400); + }); + + it("persists job to DB", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "POST", "/batch"); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValue(makeAiResponse("ok")); + + const res = makeRes(); + await ep.handler( + fakeReq({ + method: "POST", + body: { jobs: [{ templateSlug: "blog-post", variables: {} }] }, + }), + res as never, + ); + + const data = res._data as BatchJobRecord; + const stored = await ctx.api.db.get(buildJobKey(data.jobId)); + expect(stored).toBeTruthy(); + }); +}); + +// ── GET /batch/:jobId ───────────────────────────────────────────────────────── + +describe("GET /batch/:jobId", () => { + it("returns stored job record", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + + // First create a batch job + const postEp = getEndpoint(eps, "POST", "/batch"); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue(makeAiResponse("ok")); + const postRes = makeRes(); + await postEp.handler( + fakeReq({ + method: "POST", + body: { jobs: [{ templateSlug: "blog-post", variables: {} }] }, + }), + postRes as never, + ); + const created = postRes._data as BatchJobRecord; + + // Now poll it + const getEp = getEndpoint(eps, "GET", "/batch/:jobId"); + const getRes = makeRes(); + await getEp.handler( + fakeReq({ params: { jobId: created.jobId } }), + getRes as never, + ); + + const data = getRes._data as BatchJobRecord; + expect(data.jobId).toBe(created.jobId); + expect(data.status).toBe("completed"); + }); + + it("returns 404 for unknown job ID", async () => { + const ctx = makeCtx(); + const eps = await initPlugin(ctx); + const ep = getEndpoint(eps, "GET", "/batch/:jobId"); + + const res = makeRes(); + await ep.handler( + fakeReq({ params: { jobId: "nonexistent-job" } }), + res as never, + ); + + expect(res._status).toBe(404); + }); +}); + +// ── prompt:modify filter ────────────────────────────────────────────────────── + +describe("prompt:modify filter", () => { + function runFilter(ctx: PluginContext, prompt: string): string { + return plugin.definition.filters!["prompt:modify"]!(ctx, prompt) as string; + } + + it("returns the prompt unchanged when seoMode is off", () => { + const ctx = makeCtx(); + const result = runFilter(ctx, "Write about cats."); + expect(result).toBe("Write about cats."); + }); + + it("appends SEO suffix when seoMode is on", () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "seoMode" ? true : undefined, + ); + const result = runFilter(ctx, "Write about cats."); + expect(result).toContain("Write about cats."); + expect(result).toContain("Optimize this content for SEO"); + }); + + it("includes focus keyword in SEO suffix when present in config", () => { + const ctx = makeCtx({}, { focusKeyword: "AI writing" }); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "seoMode" ? true : undefined, + ); + const result = runFilter(ctx, "Write a post."); + expect(result).toContain("AI writing"); + }); + + it("resolves {{variable}} placeholders from context.config", () => { + const ctx = makeCtx({}, { topic: "machine learning" }); + const result = runFilter(ctx, "Write about {{topic}}."); + expect(result).toContain("machine learning"); + }); + + it("leaves unresolved placeholders intact", () => { + const ctx = makeCtx({}, {}); + const result = runFilter(ctx, "Hello {{name}}."); + expect(result).toBe("Hello {{name}}."); + }); + + it("tolerates non-string value by coercing to string", () => { + const ctx = makeCtx(); + const result = runFilter(ctx, 42 as unknown as string); + expect(typeof result).toBe("string"); + }); +}); diff --git a/packages/plugins/official/content-generator/__tests__/tsconfig.json b/packages/plugins/official/content-generator/__tests__/tsconfig.json new file mode 100644 index 0000000..71edfea --- /dev/null +++ b/packages/plugins/official/content-generator/__tests__/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "rootDir": "../../../", + "types": ["jest"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["**/*.ts", "../src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/plugins/official/content-generator/manifest.json b/packages/plugins/official/content-generator/manifest.json new file mode 100644 index 0000000..b0251fc --- /dev/null +++ b/packages/plugins/official/content-generator/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "content-generator", + "version": "1.0.0", + "description": "AI-powered content creation with 50+ prompt templates for blog posts, emails, social copy, and more.", + "entryPoint": "dist/index.js", + "author": "Agentbase Team", + "agentbaseVersion": ">=1.0.0", + "permissions": [], + "peerDependencies": {} +} diff --git a/packages/plugins/official/content-generator/package.json b/packages/plugins/official/content-generator/package.json new file mode 100644 index 0000000..6073390 --- /dev/null +++ b/packages/plugins/official/content-generator/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agentbase/plugin-content-generator", + "version": "1.0.0", + "description": "AI-powered content creation with 50+ prompt templates for blog posts, emails, social copy, and more.", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0-or-later", + "scripts": { + "build": "tsc", + "test": "jest --passWithNoTests", + "test:cov": "jest --coverage --passWithNoTests" + }, + "dependencies": { + "@agentbase/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.7.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/__tests__/**/*.test.ts" + ], + "globals": { + "ts-jest": { + "tsconfig": "./tsconfig.test.json" + } + }, + "coverageThreshold": { + "global": { + "lines": 80 + } + } + } +} diff --git a/packages/plugins/official/content-generator/src/index.ts b/packages/plugins/official/content-generator/src/index.ts new file mode 100644 index 0000000..e951520 --- /dev/null +++ b/packages/plugins/official/content-generator/src/index.ts @@ -0,0 +1,1169 @@ +/** + * Content Generator + * + * AI-powered content creation with 50+ prompt templates for blog posts, emails, + * social copy, and more. Templates are seeded into the plugin DB on first init + * and can be extended with custom entries. The generate endpoint resolves a + * template, interpolates variables, optionally appends an SEO instruction, and + * calls the platform AI service via makeRequest. + * + * @package @agentbase/plugin-content-generator + * @version 1.0.0 + */ +import { createPlugin, PluginContext } from "@agentbase/plugin-sdk"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const SUPPORTED_MODELS = [ + "gpt-4o", + "gpt-4o-mini", + "claude-3-5-sonnet", + "gemini-2-0-flash", +] as const; + +export type SupportedModel = (typeof SUPPORTED_MODELS)[number]; +export const DEFAULT_MODEL: SupportedModel = "gpt-4o"; +export const DEFAULT_TEMPERATURE = 0.7; + +/** Internal platform AI completions endpoint (overridable in tests via mock). */ +export const AI_COMPLETIONS_PATH = "/api/v1/internal/ai/completions"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ContentTemplate { + slug: string; + name: string; + category: string; + description: string; + /** Prompt body with {{variable}} placeholders. */ + prompt: string; + /** Names of variables the template requires. */ + variables: string[]; + builtin: boolean; +} + +export interface GenerateJobRequest { + templateSlug: string; + variables: Record; + model?: string; + temperature?: number; + keyword?: string; +} + +export interface BatchJobRecord { + jobId: string; + status: "pending" | "processing" | "completed" | "failed"; + jobs: GenerateJobRequest[]; + results: Array<{ index: number; text?: string; error?: string }>; + createdAt: number; + completedAt?: number; +} + +// ── Built-in Templates ──────────────────────────────────────────────────────── + +/* eslint-disable prettier/prettier */ +export const BUILT_IN_TEMPLATES: ContentTemplate[] = [ + // ── Blog & Content (10) ────────────────────────────────────────────────── + { + slug: "blog-post", + name: "Blog Post", + category: "Blog & Content", + description: "Full-length blog article with intro, body, and conclusion.", + prompt: + "Write a comprehensive blog post titled '{{title}}' about {{topic}}. Target audience: {{audience}}. Tone: {{tone}}. Include an engaging introduction, 3–5 detailed sections with subheadings, and a strong conclusion with a call to action. Approximately {{wordCount}} words.", + variables: ["title", "topic", "audience", "tone", "wordCount"], + builtin: true, + }, + { + slug: "how-to-guide", + name: "How-To Guide", + category: "Blog & Content", + description: "Step-by-step instructional guide for any task or process.", + prompt: + "Write a detailed how-to guide titled 'How to {{task}}'. Break it into clear numbered steps. Include a brief intro explaining why this matters, the steps themselves with tips for each, and a summary. Target reader: {{audience}}.", + variables: ["task", "audience"], + builtin: true, + }, + { + slug: "listicle", + name: "Listicle", + category: "Blog & Content", + description: "Engaging numbered list article.", + prompt: + "Write a '{{number}} {{thingType}} for {{goal}}' listicle. Each item should have a bold title and 2–3 sentences of explanation. Tone: {{tone}}. Include a short intro and outro.", + variables: ["number", "thingType", "goal", "tone"], + builtin: true, + }, + { + slug: "case-study", + name: "Case Study", + category: "Blog & Content", + description: "Customer success story in problem-solution-result format.", + prompt: + "Write a case study about {{company}} who used {{product}} to solve {{problem}}. Structure: 1) Background & Challenge, 2) Solution Implemented, 3) Results & Metrics (use these stats: {{results}}), 4) Key Takeaways. Professional tone.", + variables: ["company", "product", "problem", "results"], + builtin: true, + }, + { + slug: "product-comparison", + name: "Product Comparison", + category: "Blog & Content", + description: + "Side-by-side comparison article between two or more products.", + prompt: + "Write a product comparison article: '{{productA}} vs {{productB}}: Which is Better for {{useCase}}?'. Cover: overview of each product, feature-by-feature comparison table summary, pros and cons for each, and a verdict recommending the best fit for different user types.", + variables: ["productA", "productB", "useCase"], + builtin: true, + }, + { + slug: "opinion-piece", + name: "Opinion Piece", + category: "Blog & Content", + description: "Thought leadership or editorial on a trending topic.", + prompt: + "Write an opinion piece about {{topic}} from the perspective that {{stance}}. Support the argument with {{numberOfPoints}} distinct points, relevant examples, and a compelling call to action. Tone: {{tone}}.", + variables: ["topic", "stance", "numberOfPoints", "tone"], + builtin: true, + }, + { + slug: "interview-questions", + name: "Interview Questions", + category: "Blog & Content", + description: "Curated interview question set for a guest or expert.", + prompt: + "Generate {{count}} insightful interview questions for {{interviewee}}, who is an expert in {{expertise}}. Mix high-level strategic questions with specific tactical questions. Include one fun/personal question at the end.", + variables: ["count", "interviewee", "expertise"], + builtin: true, + }, + { + slug: "faq-section", + name: "FAQ Section", + category: "Blog & Content", + description: "Frequently asked questions block for a product or topic.", + prompt: + "Write a FAQ section about {{topic}} with {{count}} questions and answers. Cover common beginner questions, technical questions, and objection-handling questions. Format each as a bold question followed by a concise answer. Context: {{context}}.", + variables: ["topic", "count", "context"], + builtin: true, + }, + { + slug: "press-release", + name: "Press Release", + category: "Blog & Content", + description: "Professional news announcement in AP press release style.", + prompt: + "Write a press release announcing {{announcement}} by {{company}}. Include: headline, dateline ({{city}}, {{date}}), opening paragraph with who/what/when/where/why, two supporting paragraphs with quotes from {{spokesperson}}, boilerplate about {{company}}, and contact information placeholder. AP style.", + variables: ["announcement", "company", "city", "date", "spokesperson"], + builtin: true, + }, + { + slug: "product-description", + name: "Product Description", + category: "Blog & Content", + description: "Compelling e-commerce or marketing product description.", + prompt: + "Write a {{length}}-word product description for {{productName}}. Key features: {{features}}. Target buyer: {{buyer}}. Tone: {{tone}}. Lead with the biggest benefit, weave in features naturally, and end with a compelling reason to buy now.", + variables: ["length", "productName", "features", "buyer", "tone"], + builtin: true, + }, + + // ── Email Marketing (8) ────────────────────────────────────────────────── + { + slug: "email-subject-line", + name: "Email Subject Lines", + category: "Email Marketing", + description: "Five high-converting subject line variants for A/B testing.", + prompt: + "Write 5 email subject line variants for an email about {{topic}} targeting {{audience}}. Mix styles: curiosity gap, urgency, benefit-led, question, and personalisation token (e.g. '{{firstName}}, ...'). Keep each under 50 characters.", + variables: ["topic", "audience"], + builtin: true, + }, + { + slug: "cold-email", + name: "Cold Outreach Email", + category: "Email Marketing", + description: "Personalised B2B cold email for sales prospecting.", + prompt: + "Write a cold outreach email from {{senderName}} at {{senderCompany}} to {{recipientRole}} at {{recipientCompany}}. The offer is {{offer}}. Pain point: {{painPoint}}. Keep it under 120 words. End with a single low-friction call to action.", + variables: [ + "senderName", + "senderCompany", + "recipientRole", + "recipientCompany", + "offer", + "painPoint", + ], + builtin: true, + }, + { + slug: "welcome-email", + name: "Welcome Email", + category: "Email Marketing", + description: "Onboarding welcome email for new subscribers or customers.", + prompt: + "Write a warm welcome email from {{brand}} to a new {{userType}}. Include: a genuine welcome, what they can expect, one key action to take right now ({{firstAction}}), and a personal sign-off from {{senderName}}. Tone: {{tone}}.", + variables: ["brand", "userType", "firstAction", "senderName", "tone"], + builtin: true, + }, + { + slug: "newsletter", + name: "Newsletter", + category: "Email Marketing", + description: "Weekly or monthly newsletter for subscribers.", + prompt: + "Write a newsletter for {{brand}} subscribers on the theme '{{theme}}'. Include: a punchy opening hook, 2–3 short content sections (news, tips, or resources about {{topic}}), and a closing note. Tone: {{tone}}. Approximately {{wordCount}} words.", + variables: ["brand", "theme", "topic", "tone", "wordCount"], + builtin: true, + }, + { + slug: "promotional-email", + name: "Promotional Email", + category: "Email Marketing", + description: "Sales or discount promotion email.", + prompt: + "Write a promotional email for {{brand}} announcing {{offer}} ({{discount}} off {{product}}). Deadline: {{deadline}}. Create urgency without being pushy. Include a bold headline, body copy highlighting the value, and a clear CTA button text. Tone: {{tone}}.", + variables: ["brand", "offer", "discount", "product", "deadline", "tone"], + builtin: true, + }, + { + slug: "re-engagement-email", + name: "Re-engagement Email", + category: "Email Marketing", + description: "Win-back email for inactive subscribers or customers.", + prompt: + "Write a re-engagement email for {{brand}} targeting subscribers inactive for {{inactivePeriod}}. Acknowledge their absence warmly, show what's new ({{updates}}), and offer an incentive ({{incentive}}) to return. Include an unsubscribe option mention.", + variables: ["brand", "inactivePeriod", "updates", "incentive"], + builtin: true, + }, + { + slug: "follow-up-email", + name: "Follow-Up Email", + category: "Email Marketing", + description: "Post-meeting or post-demo follow-up email.", + prompt: + "Write a follow-up email from {{senderName}} to {{recipientName}} after {{context}}. Recap the key points discussed: {{keyPoints}}. Outline next steps: {{nextSteps}}. Keep it brief and professional.", + variables: [ + "senderName", + "recipientName", + "context", + "keyPoints", + "nextSteps", + ], + builtin: true, + }, + { + slug: "onboarding-email", + name: "Onboarding Email", + category: "Email Marketing", + description: "Day-2 or day-7 product onboarding email with tips.", + prompt: + "Write an onboarding email (day {{dayNumber}}) for new {{product}} users. Focus on helping them achieve {{milestone}}. Include 3 actionable tips with brief explanations, a link to {{resource}}, and encouragement. Tone: {{tone}}.", + variables: ["dayNumber", "product", "milestone", "resource", "tone"], + builtin: true, + }, + + // ── Social Media (8) ───────────────────────────────────────────────────── + { + slug: "instagram-caption", + name: "Instagram Caption", + category: "Social Media", + description: "Engaging Instagram caption with hashtags and CTA.", + prompt: + "Write an Instagram caption for a post about {{topic}} for {{brand}}. Tone: {{tone}}. Include an engaging opening line, 2–3 sentences of body copy, a clear call to action, and 10–15 relevant hashtags at the end.", + variables: ["topic", "brand", "tone"], + builtin: true, + }, + { + slug: "tweet-thread", + name: "Tweet Thread", + category: "Social Media", + description: "Multi-tweet educational or storytelling thread.", + prompt: + "Write a Twitter/X thread of {{tweetCount}} tweets about {{topic}}. Tweet 1 should be a hook that teases the value. Tweets 2–{{lastBodyTweet}} deliver the main insights. Final tweet should summarise and include a CTA. Keep each tweet under 280 characters.", + variables: ["tweetCount", "topic", "lastBodyTweet"], + builtin: true, + }, + { + slug: "linkedin-post", + name: "LinkedIn Post", + category: "Social Media", + description: "Professional LinkedIn post for thought leadership.", + prompt: + "Write a LinkedIn post about {{insight}} for {{authorRole}} in {{industry}}. Open with a bold one-liner hook. Use short paragraphs (1–2 sentences). Include a personal story or data point. End with a question to spark comments. 150–250 words.", + variables: ["insight", "authorRole", "industry"], + builtin: true, + }, + { + slug: "facebook-post", + name: "Facebook Post", + category: "Social Media", + description: "Engaging Facebook post for pages or groups.", + prompt: + "Write a Facebook post for {{brand}}'s page about {{topic}}. Start with an attention-grabbing question or statement. Include the main message ({{message}}), 1–2 emojis, and a clear CTA. Tone: {{tone}}. Keep it under 150 words.", + variables: ["brand", "topic", "message", "tone"], + builtin: true, + }, + { + slug: "tiktok-script", + name: "TikTok Script", + category: "Social Media", + description: "Short-form vertical video script with hook and CTA.", + prompt: + "Write a TikTok script for a {{duration}}-second video about {{topic}} for {{brand}}. Structure: Hook (0–3s): grab attention immediately. Build (4–{{buildEnd}}s): deliver the core value. Landing (last 5s): CTA. Include on-screen text suggestions in [brackets].", + variables: ["duration", "topic", "brand", "buildEnd"], + builtin: true, + }, + { + slug: "youtube-description", + name: "YouTube Description", + category: "Social Media", + description: "SEO-optimised YouTube video description with chapters.", + prompt: + "Write a YouTube description for a video titled '{{videoTitle}}' about {{topic}}. Include: 2–3 sentence summary (keyword-rich), video chapters with timestamps (use placeholder times), 3 relevant links as placeholders, and 5 hashtags. Channel: {{channel}}.", + variables: ["videoTitle", "topic", "channel"], + builtin: true, + }, + { + slug: "youtube-title", + name: "YouTube Title Variants", + category: "Social Media", + description: "Five click-worthy YouTube title options to A/B test.", + prompt: + "Write 5 YouTube title variants for a video about {{topic}} targeting {{audience}}. Mix styles: how-to, numbers, curiosity, controversey, and emotional. Each title under 70 characters and optimized for click-through-rate.", + variables: ["topic", "audience"], + builtin: true, + }, + { + slug: "podcast-show-notes", + name: "Podcast Show Notes", + category: "Social Media", + description: "Episode description, timestamps, and resource list.", + prompt: + "Write podcast show notes for episode {{episodeNumber}}: '{{episodeTitle}}'. Guest: {{guestName}} ({{guestTitle}}). Key topics discussed: {{topics}}. Include: 2-paragraph episode summary, 5–7 key takeaways as bullet points, guest bio, and placeholder timestamps for main segments.", + variables: [ + "episodeNumber", + "episodeTitle", + "guestName", + "guestTitle", + "topics", + ], + builtin: true, + }, + + // ── SEO & Web (6) ──────────────────────────────────────────────────────── + { + slug: "seo-meta-description", + name: "SEO Meta Description", + category: "SEO & Web", + description: "Click-optimised meta description under 160 characters.", + prompt: + "Write 3 meta description variants for a page titled '{{pageTitle}}' about {{topic}} targeting the keyword '{{keyword}}'. Each must be under 160 characters, include the keyword naturally, convey the page value, and end with an implicit or explicit CTA.", + variables: ["pageTitle", "topic", "keyword"], + builtin: true, + }, + { + slug: "seo-title-tag", + name: "SEO Title Tag", + category: "SEO & Web", + description: "Keyword-rich title tags under 60 characters.", + prompt: + "Write 5 SEO title tag variants for a page about {{topic}} targeting the primary keyword '{{keyword}}'. Each must be under 60 characters, lead with or include the keyword, and be click-worthy. Brand: {{brand}}.", + variables: ["topic", "keyword", "brand"], + builtin: true, + }, + { + slug: "landing-page-headline", + name: "Landing Page Headline", + category: "SEO & Web", + description: "Above-the-fold hero headline and subheadline variants.", + prompt: + "Write 5 landing page headline + subheadline pairs for {{product}}. The core value proposition: {{valueProposition}}. Target customer pain: {{pain}}. Each headline should be punchy (under 10 words) and each subheadline should expand on the benefit in 1–2 sentences.", + variables: ["product", "valueProposition", "pain"], + builtin: true, + }, + { + slug: "landing-page-copy", + name: "Landing Page Copy", + category: "SEO & Web", + description: "Full landing page copy sections: hero, features, CTA.", + prompt: + "Write full landing page copy for {{product}} targeting {{audience}}. Include: Hero (headline + subheadline + CTA), Problem section (3 pain points), Solution section (3 key benefits of {{product}}), Social proof placeholder, FAQ (3 questions), and final CTA. Tone: {{tone}}.", + variables: ["product", "audience", "tone"], + builtin: true, + }, + { + slug: "about-page", + name: "About Page", + category: "SEO & Web", + description: "Company or personal brand about page copy.", + prompt: + "Write an About page for {{brand}}. Founded: {{founded}}. Mission: {{mission}}. Team size: {{teamSize}}. Key achievements: {{achievements}}. Include: a compelling origin story, what makes them different, the team ethos, and a human closing paragraph. Tone: {{tone}}.", + variables: [ + "brand", + "founded", + "mission", + "teamSize", + "achievements", + "tone", + ], + builtin: true, + }, + { + slug: "service-page", + name: "Service Page", + category: "SEO & Web", + description: "Service or offering page with benefits and process.", + prompt: + "Write a service page for {{service}} offered by {{company}}. Include: headline, 3-sentence overview, key benefits ({{benefits}}), how it works (3 steps), who it's for ({{audience}}), pricing mention placeholder, and CTA. SEO keyword: {{keyword}}.", + variables: ["service", "company", "benefits", "audience", "keyword"], + builtin: true, + }, + + // ── Business (8) ───────────────────────────────────────────────────────── + { + slug: "sales-proposal", + name: "Sales Proposal", + category: "Business", + description: "Professional B2B sales proposal document.", + prompt: + "Write a sales proposal from {{senderCompany}} to {{prospectCompany}} for {{solution}}. Sections: Executive Summary, Understanding of the Problem ({{problem}}), Proposed Solution, Scope of Work, Investment (placeholder pricing), Timeline, and Next Steps. Professional, confident tone.", + variables: ["senderCompany", "prospectCompany", "solution", "problem"], + builtin: true, + }, + { + slug: "meeting-summary", + name: "Meeting Summary", + category: "Business", + description: "Structured meeting recap with decisions and action items.", + prompt: + "Write a meeting summary for a {{meetingType}} meeting on {{date}} between {{attendees}}. Topics discussed: {{topics}}. Format: Attendees list, Agenda items with discussion summaries, Key decisions made, Action items with owners and due dates (use name: {{actionOwner}} as placeholder), and Next meeting date.", + variables: ["meetingType", "date", "attendees", "topics", "actionOwner"], + builtin: true, + }, + { + slug: "executive-summary", + name: "Executive Summary", + category: "Business", + description: "One-page executive summary for reports or proposals.", + prompt: + "Write a 300-word executive summary for {{documentTitle}}. The document covers {{topic}}. Key findings: {{findings}}. Recommendations: {{recommendations}}. The audience is {{audience}}. Write in a clear, decisive, executive-appropriate tone.", + variables: [ + "documentTitle", + "topic", + "findings", + "recommendations", + "audience", + ], + builtin: true, + }, + { + slug: "business-plan-section", + name: "Business Plan Section", + category: "Business", + description: "Individual section for a business plan document.", + prompt: + "Write the '{{sectionName}}' section of a business plan for {{company}}, a {{businessType}} company. The company: {{description}}. Target market: {{targetMarket}}. Include relevant data placeholders and structure appropriate for investor readers. 300–400 words.", + variables: [ + "sectionName", + "company", + "businessType", + "description", + "targetMarket", + ], + builtin: true, + }, + { + slug: "investor-pitch", + name: "Investor Pitch Narrative", + category: "Business", + description: "Compelling investor pitch story for decks and calls.", + prompt: + "Write an investor pitch narrative for {{company}}. Problem: {{problem}}. Solution: {{solution}}. Market size: {{marketSize}}. Traction: {{traction}}. Ask: {{ask}}. Structure it as a story arc (problem → insight → solution → why now → why us → traction → ask). 300–400 words.", + variables: [ + "company", + "problem", + "solution", + "marketSize", + "traction", + "ask", + ], + builtin: true, + }, + { + slug: "partnership-proposal", + name: "Partnership Proposal", + category: "Business", + description: "Business partnership or collaboration proposal.", + prompt: + "Write a partnership proposal from {{company1}} to {{company2}} for {{partnershipType}}. Include: introduction of both companies, the partnership opportunity, mutual benefits for each party, proposed terms outline, and a next steps section. Professional and collaborative tone.", + variables: ["company1", "company2", "partnershipType"], + builtin: true, + }, + { + slug: "company-bio", + name: "Company Bio", + category: "Business", + description: "Short company biography for directories or press.", + prompt: + "Write a {{length}}-word company bio for {{company}}. Founded: {{founded}} by {{founders}}. What they do: {{description}}. Notable achievements: {{achievements}}. Include mission and close with a forward-looking statement. Third-person, professional tone.", + variables: [ + "length", + "company", + "founded", + "founders", + "description", + "achievements", + ], + builtin: true, + }, + { + slug: "mission-statement", + name: "Mission & Vision Statement", + category: "Business", + description: "Crisp mission and vision statements for a company.", + prompt: + "Write 3 mission statement variants and 3 vision statement variants for {{company}}. What they do: {{what}}. Who they serve: {{who}}. Why they exist: {{why}}. Each mission statement under 30 words, each vision statement under 25 words. Aspirational but grounded.", + variables: ["company", "what", "who", "why"], + builtin: true, + }, + + // ── Product & Sales (5) ────────────────────────────────────────────────── + { + slug: "product-launch-email", + name: "Product Launch Email", + category: "Product & Sales", + description: "Announcement email for a new product launch.", + prompt: + "Write a product launch email from {{brand}} announcing {{productName}}. Key benefits: {{benefits}}. Early access offer: {{offer}}. Create excitement and urgency. Include a hero headline, 3-bullet feature highlights, a bold CTA, and a P.S. with a bonus.", + variables: ["brand", "productName", "benefits", "offer"], + builtin: true, + }, + { + slug: "feature-announcement", + name: "Feature Announcement", + category: "Product & Sales", + description: "In-app or email announcement of a new product feature.", + prompt: + "Write a feature announcement for {{product}} users about the new '{{featureName}}' feature. Explain: what it is, why {{company}} built it (user pain it solves: {{problem}}), how to access it, and 3 ways it will help them. Friendly, product-voice tone.", + variables: ["product", "featureName", "company", "problem"], + builtin: true, + }, + { + slug: "customer-success-story", + name: "Customer Success Story", + category: "Product & Sales", + description: "Short-form customer success snippet for sales collateral.", + prompt: + "Write a 150-word customer success story about {{customerName}} ({{customerRole}} at {{customerCompany}}) who used {{product}} to achieve {{result}}. Quote from customer: {{quote}}. Structure: challenge → solution → result. Suitable for sales decks or website.", + variables: [ + "customerName", + "customerRole", + "customerCompany", + "product", + "result", + "quote", + ], + builtin: true, + }, + { + slug: "upsell-copy", + name: "Upsell / Cross-sell Copy", + category: "Product & Sales", + description: "In-app or email copy to upsell existing customers.", + prompt: + "Write upsell copy from {{brand}} to existing {{currentPlan}} customers upgrading to {{upgradePlan}}. Highlight 3 new capabilities they'll unlock: {{capabilities}}. Address the cost objection with a ROI framing. Soft, consultative tone. Include a CTA and a risk-reversal line.", + variables: ["brand", "currentPlan", "upgradePlan", "capabilities"], + builtin: true, + }, + { + slug: "customer-win-back", + name: "Customer Win-Back", + category: "Product & Sales", + description: "Re-engage churned customers with a compelling offer.", + prompt: + "Write a win-back email from {{brand}} to a customer who cancelled {{product}} {{timeAgo}}. Acknowledge their decision, highlight what has improved since they left ({{improvements}}), and offer {{incentive}} to return. Humble and genuine tone. Short (under 150 words).", + variables: ["brand", "product", "timeAgo", "improvements", "incentive"], + builtin: true, + }, + + // ── HR & People (5) ────────────────────────────────────────────────────── + { + slug: "job-description", + name: "Job Description", + category: "HR & People", + description: "Inclusive, compelling job posting for any role.", + prompt: + "Write a job description for a {{jobTitle}} at {{company}}. Team: {{team}}. Key responsibilities: {{responsibilities}}. Requirements: {{requirements}}. Include a company intro (2 sentences), role summary, responsibilities (5 bullets), requirements (5 bullets, distinguishing must-have from nice-to-have), and a DEI statement placeholder. Inclusive language.", + variables: [ + "jobTitle", + "company", + "team", + "responsibilities", + "requirements", + ], + builtin: true, + }, + { + slug: "performance-review", + name: "Performance Review", + category: "HR & People", + description: "Manager-written performance review for an employee.", + prompt: + "Write a performance review for {{employeeName}} ({{role}}) for the period {{period}}. Highlights: {{achievements}}. Areas to develop: {{developmentAreas}}. Overall rating: {{rating}}. Include: summary paragraph, 3 specific achievements with impact, 2 development areas with actionable suggestions, and a forward-looking goal-setting paragraph.", + variables: [ + "employeeName", + "role", + "period", + "achievements", + "developmentAreas", + "rating", + ], + builtin: true, + }, + { + slug: "cover-letter", + name: "Cover Letter", + category: "HR & People", + description: "Tailored cover letter for a job application.", + prompt: + "Write a cover letter from {{applicantName}} applying for the {{jobTitle}} role at {{company}}. Relevant experience: {{experience}}. Key skills: {{skills}}. Why this company: {{whyCompany}}. Three paragraphs: hook + intent, experience evidence, culture fit + CTA. Professional, confident, not generic.", + variables: [ + "applicantName", + "jobTitle", + "company", + "experience", + "skills", + "whyCompany", + ], + builtin: true, + }, + { + slug: "interview-feedback", + name: "Interview Feedback", + category: "HR & People", + description: "Structured post-interview feedback for hiring decisions.", + prompt: + "Write interview feedback for candidate {{candidateName}} who interviewed for {{role}} on {{date}}. Interviewer: {{interviewer}}. Strengths observed: {{strengths}}. Concerns: {{concerns}}. Overall recommendation: {{recommendation}}. Format as a structured scorecard with a narrative summary at the end.", + variables: [ + "candidateName", + "role", + "date", + "interviewer", + "strengths", + "concerns", + "recommendation", + ], + builtin: true, + }, + { + slug: "team-update", + name: "Team Update", + category: "HR & People", + description: "Weekly or monthly team status update communication.", + prompt: + "Write a team update email from {{managerName}} to the {{teamName}} team for {{period}}. Cover: key wins ({{wins}}), ongoing work, blockers ({{blockers}}), upcoming priorities ({{priorities}}), and a short motivational close. Friendly, direct tone. Bullet points where appropriate.", + variables: [ + "managerName", + "teamName", + "period", + "wins", + "blockers", + "priorities", + ], + builtin: true, + }, + + // ── Creative (5) ───────────────────────────────────────────────────────── + { + slug: "product-tagline", + name: "Product Tagline", + category: "Creative", + description: "Memorable tagline or slogan options for a product or brand.", + prompt: + "Generate 10 tagline options for {{product}} ({{description}}). Target audience: {{audience}}. Brand personality: {{personality}}. Mix styles: benefit-led, emotion-led, action-led, aspirational, witty. Keep each under 8 words.", + variables: ["product", "description", "audience", "personality"], + builtin: true, + }, + { + slug: "brand-voice-sample", + name: "Brand Voice Sample", + category: "Creative", + description: "Demonstrate a brand voice across multiple content types.", + prompt: + "Write 5 content samples demonstrating the brand voice of {{brand}}. Voice attributes: {{attributes}}. The 5 samples should be: (1) a tweet, (2) an email subject line, (3) a product description sentence, (4) an error message, (5) a CTA button label. Keep each faithful to {{attributes}}.", + variables: ["brand", "attributes"], + builtin: true, + }, + { + slug: "creative-brief", + name: "Creative Brief", + category: "Creative", + description: "Agency-standard creative brief for campaigns or projects.", + prompt: + "Write a creative brief for {{projectName}} by {{brand}}. Campaign goal: {{goal}}. Target audience: {{audience}}. Key message: {{keyMessage}}. Mandatories/constraints: {{constraints}}. Deliverables: {{deliverables}}. Tone and feel: {{tone}}. Include all standard creative brief sections.", + variables: [ + "projectName", + "brand", + "goal", + "audience", + "keyMessage", + "constraints", + "deliverables", + "tone", + ], + builtin: true, + }, + { + slug: "icebreaker-questions", + name: "Icebreaker Questions", + category: "Creative", + description: "Fun icebreaker questions for team meetings or events.", + prompt: + "Write {{count}} icebreaker questions for a {{eventType}} with {{audience}}. Mix easy (everyone can answer), fun (lighthearted), and insight-revealing questions. Avoid anything too personal or political. Context: {{context}}.", + variables: ["count", "eventType", "audience", "context"], + builtin: true, + }, + { + slug: "short-story-opener", + name: "Short Story Opener", + category: "Creative", + description: "Compelling opening paragraph for a short story or novel.", + prompt: + "Write 3 different opening paragraphs (150 words each) for a story about {{premise}}. Genre: {{genre}}. Central character: {{character}}. Each opener should use a different technique: (1) in medias res, (2) scene-setting atmosphere, (3) character voice. Make each immediately compelling.", + variables: ["premise", "genre", "character"], + builtin: true, + }, + + // ── Video & Audio (5) ──────────────────────────────────────────────────── + { + slug: "video-script", + name: "Video Script", + category: "Video & Audio", + description: "Full narration script for an explainer or marketing video.", + prompt: + "Write a {{duration}}-minute video script for {{brand}} about {{topic}}. Format: scene descriptions in [brackets], narrator lines as plain text. Structure: Hook (0:00–0:15), Problem (0:15–0:45), Solution (0:45–{{solutionEnd}}), Proof ({{proofStart}}–{{proofEnd}}), CTA (last 15 seconds). Conversational, engaging tone.", + variables: [ + "duration", + "brand", + "topic", + "solutionEnd", + "proofStart", + "proofEnd", + ], + builtin: true, + }, + { + slug: "ad-script-30s", + name: "30-Second Ad Script", + category: "Video & Audio", + description: "TV, radio, or pre-roll ad script (exactly 30 seconds).", + prompt: + "Write a 30-second ad script for {{brand}} promoting {{product}}. Key message: {{message}}. CTA: {{cta}}. Structure: 0-5s grab attention, 5-20s build desire, 20-25s address objection, 25-30s CTA. Include VO (voice over) labels and any sound/visual direction in [brackets]. Word count targets ~75 words.", + variables: ["brand", "product", "message", "cta"], + builtin: true, + }, + { + slug: "webinar-intro", + name: "Webinar Introduction Script", + category: "Video & Audio", + description: "Opening script for a webinar or live online event.", + prompt: + "Write a webinar introduction script for '{{webinarTitle}}' hosted by {{hostName}} ({{hostTitle}}). Duration: approximately 3 minutes. Cover: welcome and housekeeping, host credibility, what attendees will learn ({{outcomes}}), agenda overview, and transition to the main content. Warm, professional tone.", + variables: ["webinarTitle", "hostName", "hostTitle", "outcomes"], + builtin: true, + }, + { + slug: "course-outline", + name: "Online Course Outline", + category: "Video & Audio", + description: "Structured module and lesson plan for an online course.", + prompt: + "Create a course outline for '{{courseTitle}}' teaching {{topic}} to {{audience}}. Duration: {{duration}}. Include: course description (100 words), learning outcomes (5 bullets), {{moduleCount}} modules each with a title and 3–4 lesson titles, and a final project description. Structured and comprehensive.", + variables: ["courseTitle", "topic", "audience", "duration", "moduleCount"], + builtin: true, + }, + { + slug: "podcast-intro", + name: "Podcast Intro Script", + category: "Video & Audio", + description: "Evergreen intro script played at the start of every episode.", + prompt: + "Write an evergreen podcast intro script for '{{podcastName}}' hosted by {{hostName}}. The show is about {{topic}} for {{audience}}. Keep it under 30 seconds when spoken aloud (~60–70 words). Include the show name, host name, tagline, and a brief audience promise. Energetic, memorable tone.", + variables: ["podcastName", "hostName", "topic", "audience"], + builtin: true, + }, +]; +/* eslint-enable prettier/prettier */ + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Plugin DB key for a template record. */ +export function buildTemplateKey(slug: string): string { + return `template:${slug}`; +} + +/** Plugin DB key for a batch job record. */ +export function buildJobKey(jobId: string): string { + return `generated:${jobId}`; +} + +/** Generate a random job ID. */ +export function generateJobId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 9); +} + +/** + * Replace `{{variable}}` placeholders in a prompt string with values from a + * record. Unresolved placeholders are left as-is. + */ +export function applyTemplate( + prompt: string, + variables: Record, +): string { + return prompt.replace(/\{\{(\w+)\}\}/g, (match, key: string) => + Object.prototype.hasOwnProperty.call(variables, key) + ? variables[key] + : match, + ); +} + +/** + * Calculate the density of a keyword in a body of text. + * Returns a value between 0 (absent) and 1 (every word). + */ +export function analyzeKeywordDensity(text: string, keyword: string): number { + if (!text || !keyword) return 0; + const words = text.toLowerCase().match(/\b\w+\b/g) ?? []; + if (words.length === 0) return 0; + const kw = keyword.toLowerCase(); + const occurrences = words.filter((w) => w === kw).length; + return occurrences / words.length; +} + +/** + * Build an SEO instruction suffix to append to a prompt when seoMode is on. + * If a keyword is provided, include density guidance; otherwise give generic SEO advice. + */ +export function buildSeoSuffix(keyword?: string): string { + const base = + "\n\nOptimize this content for SEO: use natural language, appropriate subheadings (H2/H3), and a reading level suitable for a general audience."; + if (keyword) { + return ( + base + + ` Include the keyword "${keyword}" at a density of 1–2% — naturally woven into the text, not stuffed.` + ); + } + return base; +} + +// ── Seed helper ─────────────────────────────────────────────────────────────── + +/** + * Seed all built-in templates into the plugin DB if they are not already + * present. Called once inside app:init to make the library immediately usable. + */ +async function seedBuiltinTemplates(context: PluginContext): Promise { + for (const tpl of BUILT_IN_TEMPLATES) { + const key = buildTemplateKey(tpl.slug); + const existing = await context.api.db.get(key); + if (!existing) { + await context.api.db.set(key, tpl); + } + } +} + +// ── Plugin ──────────────────────────────────────────────────────────────────── + +export default createPlugin({ + name: "content-generator", + version: "1.0.0", + description: + "AI-powered content creation with 50+ prompt templates for blog posts, emails, social copy, and more.", + author: "Agentbase Team", + + // ── Settings ─────────────────────────────────────────────────────────────── + settings: { + defaultModel: { + type: "select", + label: "Default AI Model", + options: [...SUPPORTED_MODELS], + default: DEFAULT_MODEL, + }, + temperature: { + type: "number", + label: "Temperature (0–1)", + default: DEFAULT_TEMPERATURE, + }, + seoMode: { + type: "boolean", + label: "SEO Mode", + default: false, + }, + }, + + // ── Hooks ────────────────────────────────────────────────────────────────── + hooks: { + /** + * app:init — seed the built-in template library and register all endpoints. + * Endpoint handlers close over `context` so they can access the plugin DB. + */ + "app:init": async (context: PluginContext) => { + context.api.log("Content Generator initialized — seeding templates"); + await seedBuiltinTemplates(context); + + // ── GET /templates ───────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "GET", + path: "/templates", + auth: true, + description: "List all available content templates.", + handler: async (_req, res) => { + const keys = await context.api.db.keys("template:"); + const templates: ContentTemplate[] = []; + for (const key of keys) { + const tpl = (await context.api.db.get(key)) as ContentTemplate; + if (tpl) templates.push(tpl); + } + templates.sort((a, b) => + `${a.category}:${a.name}`.localeCompare(`${b.category}:${b.name}`), + ); + res.json({ templates, total: templates.length }); + }, + }); + + // ── GET /templates/:id ───────────────────────────────────────────── + context.api.registerEndpoint({ + method: "GET", + path: "/templates/:id", + auth: true, + description: "Retrieve a single template by slug.", + handler: async (req, res) => { + const slug = req.params["id"]; + const tpl = await context.api.db.get(buildTemplateKey(slug)); + if (!tpl) { + res.status(404).json({ error: "Template not found" }); + return; + } + res.json({ template: tpl }); + }, + }); + + // ── POST /generate ───────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/generate", + auth: true, + description: "Generate content from a single template.", + handler: async (req, res) => { + const { + templateSlug, + variables = {}, + model, + temperature, + keyword, + } = (req.body ?? {}) as Partial; + + if (!templateSlug) { + res.status(400).json({ error: "templateSlug is required" }); + return; + } + + const tpl = (await context.api.db.get( + buildTemplateKey(templateSlug), + )) as ContentTemplate | null; + if (!tpl) { + res.status(404).json({ error: "Template not found" }); + return; + } + + const resolvedModel = + model ?? + (context.api.getConfig("defaultModel") as string | undefined) ?? + DEFAULT_MODEL; + const resolvedTemp = + temperature ?? + (context.api.getConfig("temperature") as number | undefined) ?? + DEFAULT_TEMPERATURE; + const seoMode = + (context.api.getConfig("seoMode") as boolean | undefined) ?? false; + + let prompt = applyTemplate(tpl.prompt, variables ?? {}); + if (seoMode) { + prompt += buildSeoSuffix(keyword); + } + + const aiResp = await context.api.makeRequest(AI_COMPLETIONS_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: resolvedModel, + temperature: resolvedTemp, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (!aiResp.ok) { + res.status(502).json({ error: "AI service unavailable" }); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (await (aiResp as any).json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = json.choices?.[0]?.message?.content ?? ""; + + res.json({ text, templateSlug, model: resolvedModel }); + }, + }); + + // ── POST /batch ──────────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/batch", + auth: true, + description: + "Submit a batch of generation jobs; poll via GET /batch/:jobId.", + handler: async (req, res) => { + const { jobs } = (req.body ?? {}) as { + jobs?: GenerateJobRequest[]; + }; + + if (!Array.isArray(jobs) || jobs.length === 0) { + res.status(400).json({ error: "jobs array is required" }); + return; + } + + const jobId = generateJobId(); + const record: BatchJobRecord = { + jobId, + status: "processing", + jobs, + results: [], + createdAt: Date.now(), + }; + await context.api.db.set(buildJobKey(jobId), record); + + // Process each job, collecting results even on partial failure. + const results: BatchJobRecord["results"] = []; + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]!; + try { + const tpl = (await context.api.db.get( + buildTemplateKey(job.templateSlug), + )) as ContentTemplate | null; + if (!tpl) { + results.push({ + index: i, + error: `Template not found: ${job.templateSlug}`, + }); + continue; + } + + const seoMode = + (context.api.getConfig("seoMode") as boolean | undefined) ?? + false; + let prompt = applyTemplate(tpl.prompt, job.variables ?? {}); + if (seoMode) prompt += buildSeoSuffix(job.keyword); + + const resolvedModel = + job.model ?? + (context.api.getConfig("defaultModel") as string | undefined) ?? + DEFAULT_MODEL; + const resolvedTemp = + job.temperature ?? + (context.api.getConfig("temperature") as number | undefined) ?? + DEFAULT_TEMPERATURE; + + const aiResp = await context.api.makeRequest( + AI_COMPLETIONS_PATH, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: resolvedModel, + temperature: resolvedTemp, + messages: [{ role: "user", content: prompt }], + }), + }, + ); + + if (!aiResp.ok) { + results.push({ index: i, error: "AI service unavailable" }); + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (await (aiResp as any).json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + results.push({ + index: i, + text: json.choices?.[0]?.message?.content ?? "", + }); + } catch (err: unknown) { + results.push({ + index: i, + error: err instanceof Error ? err.message : "Unknown error", + }); + } + } + + const updated: BatchJobRecord = { + ...record, + status: "completed", + results, + completedAt: Date.now(), + }; + await context.api.db.set(buildJobKey(jobId), updated); + + res.json(updated); + }, + }); + + // ── GET /batch/:jobId ────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "GET", + path: "/batch/:jobId", + auth: true, + description: "Poll the status of a batch generation job.", + handler: async (req, res) => { + const { jobId } = req.params; + const record = await context.api.db.get(buildJobKey(jobId)); + if (!record) { + res.status(404).json({ error: "Batch job not found" }); + return; + } + res.json(record); + }, + }); + }, + }, + + // ── Filters ──────────────────────────────────────────────────────────────── + filters: { + /** + * prompt:modify — if SEO mode is enabled, append an SEO optimisation + * instruction to every prompt that goes through the conversation system. + * Also resolves any remaining {{variable}} placeholders from context.config. + */ + "prompt:modify": (context: PluginContext, value: unknown): string => { + let prompt = typeof value === "string" ? value : String(value ?? ""); + + // Resolve any template variables present in context.config. + const configVars = context.config as Record; + if (/\{\{\w+\}\}/.test(prompt)) { + prompt = applyTemplate(prompt, configVars); + } + + // Append SEO suffix when seoMode is active. + const seoMode = + (context.api.getConfig("seoMode") as boolean | undefined) ?? false; + if (seoMode) { + const keyword = context.config["focusKeyword"] as string | undefined; + prompt += buildSeoSuffix(keyword); + } + + return prompt; + }, + }, +}); diff --git a/packages/plugins/official/content-generator/tsconfig.json b/packages/plugins/official/content-generator/tsconfig.json new file mode 100644 index 0000000..1ec1969 --- /dev/null +++ b/packages/plugins/official/content-generator/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noEmit": true, + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/plugins/official/content-generator/tsconfig.test.json b/packages/plugins/official/content-generator/tsconfig.test.json new file mode 100644 index 0000000..27e1656 --- /dev/null +++ b/packages/plugins/official/content-generator/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "lib": ["ES2022", "DOM"], + "types": ["jest"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ab522..959c40c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,25 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/plugins/official/content-generator: + dependencies: + '@agentbase/plugin-sdk': + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/plugins/template: dependencies: '@agentbase/plugin-sdk':