Files
ClaudeMarketing/lib/claude.ts
T
Trey t 80a1ffbe4d feat: add multi-app support with app switcher, per-app branding, and filtered queries
Apps share the same backend, API keys, and publishing flow but each gets its own
branding (name, colors, icon, app URL), knowledge files (brand identity, product
info, platform guidelines), and campaigns. The pipeline dynamically writes
_knowledge/ files and copies app assets before each run.

- Add App model with slug, colors, appUrl, and knowledge markdown fields
- Add appId FK to Campaign, seed honeyDue as first app with existing knowledge
- App switcher dropdown in sidebar with icon previews
- Filter campaigns, stats, and assets by active app (cookie-based)
- De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic
  _knowledge/ and Remotion asset copying
- App management pages (list, create, edit) with icon upload and color pickers
- Asset library sort options (newest, oldest, name, platform, type)
- Asset cards show creation date
- Remotion HoneyDueAd accepts colors/appName props

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:21:45 -05:00

802 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "child_process";
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
import path from "path";
import { EventEmitter } from "events";
import { prisma } from "./prisma";
import { scanOutputDirectory } from "./scanner";
import { getAllSettings } from "./settings";
export const pipelineEvents = new EventEmitter();
pipelineEvents.setMaxListeners(50);
const AGENT_STEPS = [
"trend-scout",
"marketing-research-agent",
"script-writer",
"ad-creative-designer",
"video-ad-producer",
"copywriter-agent",
"distribution-agent",
] as const;
export type AgentName = (typeof AGENT_STEPS)[number];
export interface AppConfig {
name: string;
slug: string;
primaryColor: string;
accentColor: string;
darkBg: string;
assetsDir: string;
brandIdentity: string | null;
productInfo: string | null;
platformGuidelines: string | null;
}
interface CampaignConfig {
name: string;
platforms: string[];
goal: string;
keyMessage: string;
socialProof?: string;
variations?: number;
useTrendReport?: boolean;
targetAudience?: string;
visualDirection?: string;
competitorApps?: string;
screenshots?: string[];
}
/**
* Build a detailed campaign prompt that gives each agent enough context.
*/
export function buildCampaignPrompt(config: CampaignConfig, appConfig?: AppConfig): string {
const platforms = config.platforms.join(", ");
const variations = config.variations ?? 5;
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
const outputDir = `outputs/${taskName}_${dateStr}`;
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const knowledgeDir = "_knowledge";
return `# Campaign Brief: "${config.name}"
## App
${appName}
## Goal
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
## Target Platforms
${platforms}
## Key Message
${config.keyMessage}
${config.socialProof ? `## Social Proof\n${config.socialProof}` : ""}
${config.targetAudience ? `## Target Audience\n${config.targetAudience}` : ""}
${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""}
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
## Brand Colors
- Primary: ${primaryColor}
- Accent: ${accentColor}
## Content Requirements
- ${variations} hook variations per platform
- Static ads at exact platform dimensions (see ${knowledgeDir}/platform_guidelines.md)
- Video ads with platform-appropriate styles:
- Instagram: "polished" style — clean motion graphics
- TikTok: "authentic" style — raw, native feel
- Nextdoor: "local" style — warm, community-focused
- Platform-tuned captions with hashtags (except Nextdoor)
## Output Directory
All outputs MUST go to: ${outputDir}/
- ${outputDir}/ads/ — static PNG images
- ${outputDir}/scripts/ — ad scripts JSON + summary
- ${outputDir}/video/ — rendered MP4 files
- ${outputDir}/copy/ — platform caption JSON files
- ${outputDir}/trend_report.json — trend analysis
- ${outputDir}/research_results.json — research data
- ${outputDir}/research_brief.md — research summary
- ${outputDir}/Publish_${taskName}_${dateStr}.md — publish manifest
${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most recent trend_report.json and use those hooks as inspiration.\n" : ""}
## Instructions
Read CLAUDE.md first. Then execute each agent skill in order:
1. Read knowledge files (${knowledgeDir}/brand_identity.md, ${knowledgeDir}/platform_guidelines.md, ${knowledgeDir}/product_campaign.md)
2. Run trend-scout skill
3. Run marketing-research-agent skill
4. Run script-writer skill
5. Run ad-creative-designer skill
6. Run video-ad-producer skill
7. Run copywriter-agent skill
8. Run distribution-agent skill
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
}
/**
* Build a focused prompt for a single agent step.
*/
function buildAgentPrompt(
agentName: string,
campaignBrief: string,
outputDir: string,
appConfig?: AppConfig,
screenshots?: string[]
): string {
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const assetsDir = appConfig?.assetsDir ?? "assets";
const knowledgeDir = "_knowledge";
const agentInstructions: Record<string, string> = {
"trend-scout": `You are the Trend Scout agent.
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Then execute the Tavily research queries described in the skill.
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
Save your output to: ${outputDir}/trend_report.json
${campaignBrief}`,
"marketing-research-agent": `You are the Marketing Research Agent.
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
Then execute 5 deep Tavily research queries. Write a Node.js script using @tavily/core to run the searches.
Save outputs to:
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
- ${outputDir}/interactive_report.html
${campaignBrief}`,
"script-writer": `You are the Script Writer agent.
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/trend_report.json
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
Write ad scripts with hook-body-CTA structure, timed for video.
Save outputs to: ${outputDir}/scripts/
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/scripts/scripts_summary.md
- Individual script files per platform
${campaignBrief}`,
"ad-creative-designer": `You are the Ad Creative Designer agent.
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
You MUST produce TWO SETS of image assets:
---
## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
CRITICAL — REFERENCE IMAGES:
The user provided real app screenshots that MUST be used as reference images in every generate call.
Screenshot absolute paths:
${screenshots.map((p) => `\${CWD}/${p}`).join("\n")}
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
APP ICON: The ${appName} app icon is at ${assetsDir}/icon.png. This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the ${appName} icon prominently near the branding/CTA area of every ad.
For EACH ad, follow this EXACT sequence:
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
2. Then call mcp__nanobanana__gemini_generate_image with:
- prompt: A detailed description of the ad layout, headline text, brand colors (${primaryColor} blue, ${accentColor} orange), and style${screenshots?.length ? `
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
- output_path: the destination file path
3. Save to ${outputDir}/ads/gemini/
4. Name files: gemini_{platform}_{hook}_{dimensions}.png
Generate exactly 4 Gemini ads with this mix:
- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
- 1x Instagram Feed 1080x1080
- 1x TikTok 1080x1920
- 2 WITH people (lifestyle, person interacting with the app):
- 1x Instagram Stories 1080x1920
- 1x TikTok 1080x1920
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
---
## SET 2: Canvas Design Posters (Museum-quality art)
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
### Step A: Design Philosophy
First, create a visual design philosophy (.md file) for this campaign's poster aesthetic. Save it to ${outputDir}/ads/posters/design_philosophy.md
The philosophy should:
- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
- Draw from the campaign's soul
- Brand palette as foundation: ${primaryColor}, ${accentColor}, warm off-white, dark navy
### Step B: Express the Philosophy as Poster Art
Using the philosophy, create each poster as a .png file. For each:
1. Write a Python or Node.js script that generates the poster programmatically (using canvas/PDF libraries, or build HTML and screenshot with Playwright)
2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
3. Use repeating patterns, perfect geometric shapes, systematic visual language
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
5. The campaign's hook text appears as a visual accent, not a headline block
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at ${assetsDir}/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
8. The result should look like it could hang in a gallery or appear in a design magazine
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
### MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
- **Minimum font size: 44px** — NO text below 44px, ever. Clamp anything smaller up to 44px.
- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering to compensate for viewing distance.
- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
- **Body/subheadline: 44px minimum** (even if design calls for smaller)
- **Stat labels, CTA secondary text: 44px minimum**
- **Brand name: ~50px effective** (44px × 1.13)
- **Minimum gap between stacked text:**
- Giant (100-140px) above + Large below: 50px gap
- Large above + Medium below: 40px gap
- Medium + Medium: 30px gap
- Absolute minimum gap: 20px
- **2x internal render recommended:** Render at 2160×3840 (for 9:16) or 2160×2160 (for 1:1), downsample to 1080 for export — produces cleaner anti-aliasing on text and curves.
- **Export as JPEG quality=95, subsampling=0** (4:4:4 chroma) — NOT PNG. Instagram converts PNG to JPEG anyway.
- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge. All visual content centered between header bottom and CTA top.
- **Overflow check:** Always calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first (most compressible element).
Save posters to ${outputDir}/ads/posters/
Name files: poster_{platform}_{hook}_{dimensions}.png
Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
${campaignBrief}`,
"video-ad-producer": `You are the Video Ad Producer agent.
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
- ${outputDir}/ads/gemini/manifest.json
- ${outputDir}/ads/posters/manifest.json
## Phone Frame Asset
A real iPhone frame PNG is at: ${assetsDir}/phone.png (orange rim, dynamic island, transparent screen)
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
${screenshots?.length ? `
## App Screenshots
Screenshot files: ${screenshots.map((p) => `${p}`).join(", ")}
Also available in remotion-ad/public/tasks_overdue.png
For phone mockup scenes: layer the screenshot BEHIND phone.png so the dynamic islands align. The phone frame has a transparent screen area.
` : ""}
## Video Creation Rules
Create ONE video for EACH static ad in BOTH the Gemini and Canvas Poster sets. Read both manifests to get the full list.
Each video MUST match its corresponding static ad:
- Same hook text, same message, same CTA
- Same tone (if the ad is moody/dark, the video is moody/dark; if clean/minimal, the video is clean/minimal)
- The static ad image can appear as a scene in the video (e.g., the final frame)
For each video:
1. Create a Remotion composition in remotion-ad/src/ with a unique CompositionId
2. Use the phone.png frame + screenshot for the phone reveal scene
3. Structure: Hook text (2-3s) → Phone reveal with app screenshot (4-6s) → Social proof (2-3s) → CTA (2-3s)
4. Render using: cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../${outputDir}/video/filename.mp4
Video specs:
- Instagram Reels: 1080x1920, 15s, 30fps
- TikTok: 1080x1920, 12s, 30fps
Naming: video_{source}_{platform}_{hook}_{dimensions}.mp4
Where source is "gemini" or "poster"
Save scene_plans.json and all MP4s to: ${outputDir}/video/
${campaignBrief}`,
"copywriter-agent": `You are the Copywriter agent.
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/research_results.json
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
Write platform-tuned captions following the Hook → Value → CTA → Hashtags structure.
Save outputs to:
- ${outputDir}/copy/instagram_captions.json
- ${outputDir}/copy/tiktok_captions.json
- ${outputDir}/copy/nextdoor_posts.json
- ${outputDir}/copy/copy_matrix.json (maps captions to creatives)
${campaignBrief}`,
"distribution-agent": `You are the Distribution Agent.
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
Gather ALL outputs from the campaign:
- ${outputDir}/ads/ (all PNG files)
- ${outputDir}/video/ (all MP4 files)
- ${outputDir}/copy/ (all caption JSON files)
- ${outputDir}/scripts/ (scripts)
Create a publish manifest at: ${outputDir}/Publish_manifest.md
The manifest should include:
- List of all assets with file paths
- Recommended caption for each asset
- Recommended posting schedule
- Platform-specific notes
- A review checklist
IMPORTANT: Do NOT publish anything. Only create the manifest for human review.
${campaignBrief}`,
};
return agentInstructions[agentName] || `Execute the ${agentName} skill. ${campaignBrief}`;
}
const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per agent
const AGENT_LABELS: Record<string, string> = {
"trend-scout": "Trend Scout",
"marketing-research-agent": "Research Agent",
"script-writer": "Script Writer",
"ad-creative-designer": "Ad Creative Designer",
"video-ad-producer": "Video Ad Producer",
"copywriter-agent": "Copywriter",
"distribution-agent": "Distribution Agent",
};
function humanizeAgentError(agentName: string, code: number | null, stderr: string): string {
const label = AGENT_LABELS[agentName] || agentName;
// Signal-based exits
if (code === 143 || code === 137) {
return `${label} was stopped (process terminated)`;
}
if (code === 130) {
return `${label} was interrupted`;
}
// Try to extract a meaningful error from stderr, ignoring noise
const meaningful = stderr
.split("\n")
.filter((line) => {
const l = line.trim().toLowerCase();
if (!l) return false;
if (l.includes("warning:")) return false;
if (l.includes("stdin")) return false;
if (l.includes("piping from")) return false;
if (l.includes("deprecation")) return false;
return true;
})
.slice(-3)
.join(" ")
.trim();
if (meaningful) {
// Cap length and clean up
const cleaned = meaningful.slice(0, 200).replace(/\s+/g, " ");
return `${label} failed: ${cleaned}`;
}
// Generic fallback
if (code !== null && code !== 0) {
return `${label} failed (exit code ${code})`;
}
return `${label} failed unexpectedly`;
}
export async function runAgentStep(
agentName: string,
prompt: string,
cwd: string,
env: Record<string, string>
): Promise<{ output: string }> {
return new Promise((resolve, reject) => {
const args = [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
];
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// Timeout watchdog
const timeout = setTimeout(() => {
claude.kill("SIGTERM");
reject(new Error(`${agentName} timed out after ${AGENT_TIMEOUT_MS / 60000} minutes`));
}, AGENT_TIMEOUT_MS);
claude.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
claude.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
claude.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
// Extract meaningful text from stream-json stdout
let summary = "";
const lines = stdout.split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.result?.text) summary = event.result.text;
else if (event.type === "text" && event.text) summary += event.text;
} catch {
// skip non-JSON lines
}
}
resolve({ output: summary || stdout.slice(-1000) });
} else {
reject(new Error(humanizeAgentError(agentName, code, stderr)));
}
});
claude.on("error", (err) => {
clearTimeout(timeout);
reject(new Error(`Failed to start ${agentName}: ${err.message}`));
});
});
}
/**
* Load API keys from settings DB to pass to Claude subprocess environment.
*/
async function loadPipelineEnv(): Promise<Record<string, string>> {
const settings = await getAllSettings();
const env: Record<string, string> = {};
if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY;
if (settings.POSTIZ_URL) env.POSTIZ_URL = settings.POSTIZ_URL;
if (settings.POSTIZ_API_KEY) env.POSTIZ_API_KEY = settings.POSTIZ_API_KEY;
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
if (settings.NEXTDOOR_API_TOKEN) env.NEXTDOOR_API_TOKEN = settings.NEXTDOOR_API_TOKEN;
if (settings.NEXTDOOR_ADVERTISER_ID) env.NEXTDOOR_ADVERTISER_ID = settings.NEXTDOOR_ADVERTISER_ID;
return env;
}
/**
* Write the active app's knowledge files to pipeline/_knowledge/ for agents to read.
*/
function writeKnowledgeFiles(pipelineRoot: string, appConfig: AppConfig) {
const knowledgeDir = path.join(pipelineRoot, "_knowledge");
mkdirSync(knowledgeDir, { recursive: true });
if (appConfig.brandIdentity) {
writeFileSync(path.join(knowledgeDir, "brand_identity.md"), appConfig.brandIdentity, "utf-8");
}
if (appConfig.productInfo) {
writeFileSync(path.join(knowledgeDir, "product_campaign.md"), appConfig.productInfo, "utf-8");
}
if (appConfig.platformGuidelines) {
writeFileSync(path.join(knowledgeDir, "platform_guidelines.md"), appConfig.platformGuidelines, "utf-8");
}
}
/**
* Copy app's icon and phone frame to remotion-ad/public/ for video rendering.
*/
function copyAppAssetsToRemotion(pipelineRoot: string, appConfig: AppConfig) {
const remotionPublic = path.join(pipelineRoot, "remotion-ad", "public");
mkdirSync(remotionPublic, { recursive: true });
const iconSrc = path.join(pipelineRoot, appConfig.assetsDir, "icon.png");
const phoneSrc = path.join(pipelineRoot, appConfig.assetsDir, "phone.png");
if (existsSync(iconSrc)) {
copyFileSync(iconSrc, path.join(remotionPublic, "icon.png"));
}
if (existsSync(phoneSrc)) {
copyFileSync(phoneSrc, path.join(remotionPublic, "phone.png"));
}
}
export async function launchPipeline(
campaignId: string,
prompt: string,
cwd: string,
appConfig?: AppConfig
) {
// Load API keys from settings
const pipelineEnv = await loadPipelineEnv();
// Get campaign for output path and config
const campaignData = await prisma.campaign.findUnique({ where: { id: campaignId } });
const outputDir = campaignData?.outputPath || "outputs/default";
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
const screenshots: string[] = campaignConfig.screenshots || [];
// Write knowledge files and copy assets for the active app
if (appConfig) {
writeKnowledgeFiles(cwd, appConfig);
copyAppAssetsToRemotion(cwd, appConfig);
}
// Create output directories
const dirs = ["ads", "scripts", "video", "copy"];
for (const dir of dirs) {
mkdirSync(path.join(cwd, outputDir, dir), { recursive: true });
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "running" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_started",
campaignId,
});
for (const agentName of AGENT_STEPS) {
const agentRun = await prisma.agentRun.create({
data: {
campaignId,
agentName,
status: "running",
startedAt: new Date(),
},
});
pipelineEvents.emit(campaignId, {
type: "agent_started",
agentName,
agentRunId: agentRun.id,
});
const startTime = Date.now();
try {
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, appConfig, screenshots);
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
const durationMs = Date.now() - startTime;
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "completed",
completedAt: new Date(),
durationMs,
outputSummary: output.slice(0, 500),
outputPath: outputDir,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_completed",
agentName,
agentRunId: agentRun.id,
durationMs,
outputSummary: output.slice(0, 200),
});
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "failed",
completedAt: new Date(),
durationMs,
error: errorMessage,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_failed",
agentName,
agentRunId: agentRun.id,
error: errorMessage.slice(0, 200),
});
// Continue to next agent even if one fails
}
}
// Scan output directory for generated assets
if (campaignData?.outputPath) {
try {
const result = await scanOutputDirectory(campaignId, campaignData.outputPath, cwd);
pipelineEvents.emit(campaignId, {
type: "scan_complete",
scanned: result.scanned,
created: result.created,
});
} catch {
// Scanner errors shouldn't block pipeline completion
}
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "review" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_complete",
status: "review",
});
}
export async function sendChatMessage(
sessionId: string | null,
message: string,
cwd: string
): Promise<ReadableStream<Uint8Array>> {
const pipelineEnv = await loadPipelineEnv();
const args = [
"-p",
message,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
];
if (sessionId) args.push("--resume", sessionId);
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...pipelineEnv },
});
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
claude.stdout.on("data", (chunk: Buffer) => {
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// Handle various Claude CLI stream-json event types
let text = "";
if (event.type === "content_block_delta" && event.delta?.text) {
text = event.delta.text;
} else if (event.type === "assistant" && event.message) {
text = event.message;
} else if (event.type === "text" && event.text) {
text = event.text;
} else if (event.result?.text) {
text = event.result.text;
}
if (text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
);
}
// Capture session ID if provided
if (event.session_id || event.sessionId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ sessionId: event.session_id || event.sessionId })}\n\n`
)
);
}
} catch {
// Non-JSON line — skip (likely progress output)
}
}
});
claude.on("close", () => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
);
controller.close();
});
claude.on("error", () => {
controller.close();
});
},
});
}