/** * @pyre/prometheus — AI generation logic for Spawn identity (§9, §16). * * Responsibilities (§13): prompt templates, the meta mixer, output parser, * safety checks, and the image-prompt generator. See §9 (Prometheus AI Meta * Mixer) and §16 (Security / AI safety) of `docs/PYRE_MVP_DESIGN.md`. * * Design notes: * - Prometheus generates Spawn *identity* only — it never controls funds. * - Generation is PROVIDER-ABSTRACTED and STUBBED: it runs with NO network/keys * today. Real providers slot in later behind `TextProvider` / `ImageProvider` * / `ModerationProvider` (see `providers.ts`). This package adds NO SDK deps. * - The meta mixer is DETERMINISTIC from a seed derived from the input (no * `Math.random`): same input → same theme → same Spawn. The §9 "probabilistic" * weighting is realized as a fixed influence budget (40/25/20/15) sampled by a * deterministic hash so results are reproducible and testable. * - Produce inspired mutations, NOT direct clones of existing/copyrighted memes; * forbid real people, brands, and trademarks. */ import type { PrometheusInput, PrometheusGenerateResponse } from "@pyre/core"; import { StubImageProvider, StubModerationProvider, StubTextProvider, deriveInputSeed, hashString, type ImageProvider, type ModerationProvider, type TextProvider, } from "./providers.js"; export { StubImageProvider, StubModerationProvider, StubTextProvider, hashHex, hashString, deriveInputSeed, } from "./providers.js"; export type { ImageProvider, ModerationProvider, TextProvider } from "./providers.js"; // --------------------------------------------------------------------------- // Meta mixer // --------------------------------------------------------------------------- /** * §9 influence budget. Weights sum to 100. Deterministic, not random — the * "probabilistic" model is approximated by a fixed budget sampled via hashing. */ export const META_WEIGHTS = { burnedArchetypes: 40, essenceThemes: 25, chaosMutation: 20, operatorSeed: 15, } as const; /** A single influence contribution to the theme. */ export interface MetaInfluence { source: keyof typeof META_WEIGHTS; weight: number; /** Concrete theme tokens contributed by this source. */ terms: string[]; } /** The deterministic theme produced by the meta mixer. */ export interface MetaTheme { /** Deterministic seed the whole generation derives from. */ seed: number; /** Weighted influences, ordered by descending weight. */ influences: MetaInfluence[]; /** Flattened, weighted-priority theme keywords (deduped). */ keywords: string[]; /** Dominant archetype chosen deterministically from burned tokens. */ dominantArchetype: string; /** Chaos factor 0..1 driving mutation strength. */ chaos: number; } function dedupe(values: string[]): string[] { return [...new Set(values.map((v) => v.trim()).filter((v) => v.length > 0))]; } /** * Deterministically pick up to `count` items from `pool`, seeded by `seed`. * Stable for a given (pool, seed, count). */ function pick(pool: string[], count: number, seed: number): string[] { if (pool.length === 0 || count <= 0) return []; const out: string[] = []; const used = new Set(); let cursor = seed >>> 0; for (let i = 0; i < count && used.size < pool.length; i++) { // Advance the cursor deterministically and map into the pool. cursor = hashString(`${cursor}:${i}`); let idx = cursor % pool.length; // Linear-probe to the next unused slot for stable, collision-free picks. while (used.has(idx)) idx = (idx + 1) % pool.length; used.add(idx); const v = pool[idx]; if (v !== undefined) out.push(v); } return out; } /** * Run the deterministic meta mixer. Builds a weighted `MetaTheme` from the * input using the §9 influence budget (40% burned archetypes, 25% essence / * transmuted themes, 20% chaos mutation, 15% operator seed). Same input (and * `operatorSeed`) → same theme. */ export function runMetaMixer(input: PrometheusInput, operatorSeed?: string): MetaTheme { const seed = deriveInputSeed(input, operatorSeed); const chaos = Math.min(1, Math.max(0, input.chaosFactor)); // 40% — burned token archetypes (archetypes + burned token names/symbols). const burnedPool = dedupe([ ...input.dominantArchetypes, ...input.burnedTokens.flatMap((t) => [t.symbol ?? "", t.name ?? ""]), ]); // 25% — essence / transmuted themes (transmuted tokens + metadata themes). const essencePool = dedupe([ ...input.transmutedTokens.flatMap((t) => [t.symbol ?? "", t.name ?? ""]), ...input.tokenNames, ...input.tokenSymbols, ...input.metadataDescriptions, ]); // 20% — chaos mutation (deterministic mutators scaled by chaos factor). const chaosPool = [ "glitch", "feral", "molten", "fractured", "spectral", "riftborn", "ashen", "warped", ]; // 15% — operator seed (manual theme + injected operatorSeed). const operatorPool = dedupe([input.manualThemeSeed ?? "", operatorSeed ?? ""]); // Allocate counts proportional to weights (and chaos for the chaos source). const burned = pick(burnedPool, 4, seed ^ 0x40); const essence = pick(essencePool, 3, seed ^ 0x25); const chaosCount = 1 + Math.round(chaos * 3); // 1..4 mutators const chaosTerms = pick(chaosPool, chaosCount, seed ^ 0x20); const operator = pick(operatorPool, 2, seed ^ 0x15); const influences: MetaInfluence[] = [ { source: "burnedArchetypes" as const, weight: META_WEIGHTS.burnedArchetypes, terms: burned }, { source: "essenceThemes" as const, weight: META_WEIGHTS.essenceThemes, terms: essence }, { source: "chaosMutation" as const, weight: META_WEIGHTS.chaosMutation, terms: chaosTerms }, { source: "operatorSeed" as const, weight: META_WEIGHTS.operatorSeed, terms: operator }, ].sort((a, b) => b.weight - a.weight); const keywords = dedupe(influences.flatMap((i) => i.terms)); const dominantArchetype = burned[0] ?? input.dominantArchetypes[0] ?? "ember"; return { seed, influences, keywords, dominantArchetype, chaos }; } // --------------------------------------------------------------------------- // Prompt building // --------------------------------------------------------------------------- /** Anti-clone / anti-impersonation guard text reused across prompts. */ const SAFETY_GUARD = [ "Create an ORIGINAL, INSPIRED MUTATION of existing meme themes — NOT a clone", "of any existing or copyrighted meme, token, character, or brand.", "FORBIDDEN: real people (living or dead), public figures, real companies,", "brands, trademarks, logos, or any real-world likeness.", ].join(" "); /** * Build the text-generation prompt from the meta-mixer theme. Instructs the * model to produce an inspired mutation (not a clone), forbids real * people/brands/trademarks, and demands strict JSON output. */ export function buildPrompt(input: PrometheusInput, operatorSeed?: string): string { const theme = runMetaMixer(input, operatorSeed); const influenceLines = theme.influences .map((i) => `- ${i.weight}% ${i.source}: ${i.terms.join(", ") || "(none)"}`) .join("\n"); return [ "You are Prometheus, the PYRE firebringer. Forge a new meme-token Spawn", "identity from the ashes of burned tokens. This is entertainment only — no", "financial promises.", "", SAFETY_GUARD, "", `Dominant archetype: ${theme.dominantArchetype}`, `Chaos / mutation strength: ${theme.chaos.toFixed(2)}`, "Weighted meta influences (§9 meta-mixer budget):", influenceLines, "", `Theme keywords: ${theme.keywords.join(", ") || "ash, ember, rebirth"}`, "", "Output STRICT JSON ONLY, no prose, matching exactly this schema:", '{ "name": string, "ticker": string, "lore": string, "tagline": string,', ' "description": string, "imagePrompt": string }', "Ticker: 2-10 uppercase A-Z0-9 characters. Name: short and evocative.", ].join("\n"); } // --------------------------------------------------------------------------- // Image prompt // --------------------------------------------------------------------------- /** * Generate the image-generation prompt for the Spawn artwork. Carries the same * style guard: no real-person likeness, no trademarked logos/brands. */ export function generateImagePrompt( parsed: ParsedSpawn, theme: MetaTheme, ): string { const base = parsed.imagePrompt.trim().length > 0 ? parsed.imagePrompt.trim() : `${parsed.name}: a ${theme.dominantArchetype} meme creature reforged from ash`; return [ base, `Mood keywords: ${theme.keywords.slice(0, 6).join(", ") || "ash, ember, rebirth"}.`, "Style: original digital meme art, bold, high-contrast, clean silhouette.", "STYLE GUARD: do NOT depict any real person's likeness; do NOT include any", "trademarked logos, brands, or copyrighted characters; original design only.", ].join(" "); } // --------------------------------------------------------------------------- // Output parsing // --------------------------------------------------------------------------- export interface ParsedSpawn { name: string; ticker: string; lore: string; tagline: string; description: string; imagePrompt: string; } function asString(value: unknown): string { return typeof value === "string" ? value : ""; } /** * Parse + defensively validate the text JSON into typed fields. Tolerates * surrounding prose by extracting the first JSON object. Missing fields become * empty strings; the ticker is normalized to uppercase alphanumerics. */ export function parseOutput(raw: string): ParsedSpawn { let text = raw.trim(); // Strip ```json fences if a model wrapped the JSON. const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); if (fence?.[1]) text = fence[1].trim(); // Fall back to the first {...} block if there is leading/trailing prose. if (!text.startsWith("{")) { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start >= 0 && end > start) text = text.slice(start, end + 1); } let obj: Record = {}; try { const parsed: unknown = JSON.parse(text); if (parsed && typeof parsed === "object") { obj = parsed as Record; } } catch { obj = {}; } const ticker = asString(obj["ticker"]) .toUpperCase() .replace(/[^A-Z0-9]/g, "") .slice(0, 10); return { name: asString(obj["name"]).slice(0, 64), ticker, lore: asString(obj["lore"]), tagline: asString(obj["tagline"]), description: asString(obj["description"]), imagePrompt: asString(obj["imagePrompt"]), }; } // --------------------------------------------------------------------------- // Safety checks // --------------------------------------------------------------------------- /** * Local offline denylist. Categories map to the §16 "AI output abuse" defenses: * hate/slurs, explicit content, impersonation, copyright/clone, and scam terms. * Patterns are intentionally conservative and word-boundary anchored where * sensible to limit false positives. */ const DENYLIST: { category: string; patterns: RegExp[] }[] = [ { category: "hate", patterns: [/\bnazi\b/i, /\bslur\b/i, /\bgenocide\b/i, /\bkkk\b/i], }, { category: "explicit", patterns: [/\bporn\b/i, /\bnsfw\b/i, /\bexplicit sex\b/i, /\bchild\s*porn\b/i], }, { category: "impersonation", patterns: [ /\belon\s*musk\b/i, /\bdonald\s*trump\b/i, /\bofficial\b/i, /\bverified\b/i, ], }, { category: "copyright", patterns: [ /\bpepe\b/i, /\bdisney\b/i, /\bnintendo\b/i, /\bpokemon\b/i, /\bmickey\s*mouse\b/i, /\btrademark\b/i, ], }, { category: "scam", patterns: [ /\bguaranteed\s*returns?\b/i, /\b\d+x\s*guaranteed\b/i, /\brug\s*pull\b/i, /\bairdrop\s*scam\b/i, /\bsend\s*\d+\s*get\b/i, ], }, ]; /** Result of safety screening: a flat list of `category:detail` risk flags. */ export interface SafetyResult { flagged: boolean; riskFlags: string[]; } /** * Screen text against the local denylist and the injected `ModerationProvider`. * Returns combined risk flags. Defaults to the offline stub moderation. */ export async function runSafetyChecks( text: string, moderation: ModerationProvider = new StubModerationProvider(), ): Promise { const riskFlags: string[] = []; for (const { category, patterns } of DENYLIST) { for (const pattern of patterns) { const match = pattern.exec(text); if (match) { riskFlags.push(`denylist:${category}:${match[0].toLowerCase().trim()}`); } } } const mod = await moderation.check(text); if (mod.flagged) { if (mod.categories.length === 0) { riskFlags.push("moderation:flagged"); } else { for (const c of mod.categories) riskFlags.push(`moderation:${c}`); } } return { flagged: riskFlags.length > 0, riskFlags }; } // --------------------------------------------------------------------------- // Top-level orchestration // --------------------------------------------------------------------------- export interface GenerateSpawnOptions { text?: TextProvider; image?: ImageProvider; moderation?: ModerationProvider; /** Chaos factor override 0..1. Falls back to `input.chaosFactor`. */ chaos?: number; /** Operator theme seed injected at call time. */ operatorSeed?: string; } /** Max regeneration attempts before flagging and returning best-effort output. */ const MAX_ATTEMPTS = 2; /** * Orchestrate end-to-end Spawn generation: * meta-mix → buildPrompt → text.generate → parseOutput → * safety + moderation (regenerate-or-flag) → generateImagePrompt → * image.generate → assemble `PrometheusGenerateResponse`. * * Defaults to the deterministic Stub providers, so it runs offline with no keys. */ export async function generateSpawn( input: PrometheusInput, opts: GenerateSpawnOptions = {}, ): Promise { const text = opts.text ?? new StubTextProvider(); const image = opts.image ?? new StubImageProvider(); const moderation = opts.moderation ?? new StubModerationProvider(); const effectiveInput: PrometheusInput = opts.chaos === undefined ? input : { ...input, chaosFactor: opts.chaos }; const theme = runMetaMixer(effectiveInput, opts.operatorSeed); const prompt = buildPrompt(effectiveInput, opts.operatorSeed); let parsed: ParsedSpawn = { name: "", ticker: "", lore: "", tagline: "", description: "", imagePrompt: "", }; let riskFlags: string[] = []; // Regenerate-or-flag loop: a clean pass wins; otherwise keep the last attempt // and surface its risk flags for human review (§16 human approval in MVP). for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { // Vary the prompt deterministically per attempt so a regenerate differs. const attemptPrompt = attempt === 0 ? prompt : `${prompt}\n\n[regen:${attempt}]`; parsed = parseOutput(await text.generate(attemptPrompt)); const screenText = [ parsed.name, parsed.ticker, parsed.lore, parsed.tagline, parsed.description, ].join("\n"); const safety = await runSafetyChecks(screenText, moderation); riskFlags = safety.riskFlags; if (!safety.flagged) break; } const imagePrompt = generateImagePrompt(parsed, theme); // Screen the image prompt too; merge any new flags. const imageSafety = await runSafetyChecks(imagePrompt, moderation); if (imageSafety.flagged) { riskFlags = [...new Set([...riskFlags, ...imageSafety.riskFlags.map((f) => `image:${f}`)])]; } const imageResult = await image.generate(imagePrompt); return { generationId: crypto.randomUUID(), spawnName: parsed.name, ticker: parsed.ticker, lore: parsed.lore, imagePrompt, metadata: { tagline: parsed.tagline, description: parsed.description, imageUrl: imageResult.url, imageB64: imageResult.b64, themeSeed: theme.seed, themeKeywords: theme.keywords, dominantArchetype: theme.dominantArchetype, chaos: theme.chaos, }, riskFlags, }; }