feat(prometheus+spawn): Prometheus engine (stubbed) + manual Pump.fun creator

Built by 2 parallel agents (+ image-API research):
- @pyre/prometheus: generateSpawn() engine — deterministic §9 meta-mixer
  (40/25/20/15), prompt builder ("inspired mutation, not a clone" + no
  people/brands), name/ticker/lore/tagline gen, image-prompt, denylist + moderation
  safety. PROVIDER-ABSTRACTED (TextProvider/ImageProvider/ModerationProvider) with
  deterministic STUBS so it runs keyless today; real call shapes documented (Claude
  Haiku text · FLUX schnell image · OpenAI omni-moderation). 13 tests.
- @pyre/db: migration 002 (prometheus_generations, spawn_records) + record/list/get.
- @pyre/api: admin-gated POST /api/prometheus/generate + /api/spawn/launch
  (x-admin-token; CLOSED with 403 when ADMIN_API_TOKEN unset; timing-safe compare),
  public GET /api/spawns + /api/spawn/:id.
- @pyre/web: public /spawn record page; @pyre/core SpawnRecord type.

Verified: typecheck 8/8, 134 tests (core 91 + prometheus 13 + solana 30), web build
(+/spawn), migrate 002 live, /api/spawns OK, admin gate returns 403 (unconfigured).
Follow-ups: set ADMIN_API_TOKEN to use admin endpoints; wire real provider keys;
receiptId→DB-id wiring; admin generation UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 07:09:53 +00:00
parent 28064c5131
commit 8b58faf7c1
16 changed files with 1882 additions and 76 deletions

View File

@@ -9,3 +9,4 @@ export * from "./fee";
export * from "./sell";
export * from "./receipt";
export * from "./prometheus";
export * from "./spawn";

View File

@@ -0,0 +1,51 @@
/**
* Spawn-record schema (§10, §15) — the public, immutable record of a Spawn whose
* token was created on Pump.fun.
*
* In the MVP the Pump.fun launch is MANUAL / approval-gated (§10): Prometheus
* generates a Spawn package, an operator reviews it and creates the token on
* Pump.fun by hand, then records the mint / metadata URI / Pump.fun URL / launch
* tx here. These records are read-only to the public and never imply an
* investment, yield, or profit — they are ritual/entertainment artifacts.
*
* PYRE never holds keys and never signs a launch (§3, §16): the launch tx is
* produced by the operator's own wallet on Pump.fun and merely *recorded* here.
*/
/** Lifecycle status of a {@link SpawnRecord}. */
export type SpawnStatus = "launched" | "pending";
/**
* A public record of a Spawn whose token was (manually) created on Pump.fun.
*
* Mirrors the `spawn_records` table (§15). `mint` / `metadataUri` / `pumpfunUrl`
* / `launchTx` are optional because a record may be filed before every field is
* known, though the manual-launch endpoint requires the launch identifiers.
*/
export interface SpawnRecord {
/** Stable record id (string form of the DB BIGSERIAL). */
id: string;
/** The Prometheus generation this Spawn was launched from. */
generationId: string;
spawnName: string;
ticker: string;
/** SPL mint address of the launched token (base58). */
mint?: string;
/** URI of the token metadata JSON (IPFS/Arweave/etc.). */
metadataUri?: string;
/** Public Pump.fun page for the token. */
pumpfunUrl?: string;
/** Confirmed launch (create) transaction signature. */
launchTx?: string;
status: SpawnStatus;
/** ISO-8601 creation timestamp. */
createdAt: string;
/**
* Optional display image URL surfaced to the public page. Derived from the
* generation metadata (e.g. `metadata.imageUrl`) when present; the launch
* endpoint does not require it.
*/
imageUrl?: string;
/** Optional Spawn lore, carried over from the generation for display. */
lore?: string;
}

View File

@@ -0,0 +1,48 @@
-- 002_spawns.sql — Prometheus generations + Spawn records (idempotent).
--
-- Backs the manual / approval-gated Pump.fun creator workflow (§10, §15):
-- prometheus_generations — every Spawn package Prometheus generates, with the
-- mixer input, the raw output, its review status, and the risk flags raised.
-- spawn_records — the public, immutable record of a Spawn whose token
-- was MANUALLY created on Pump.fun by the operator (mint / metadata URI /
-- Pump.fun URL / launch tx). PYRE never signs the launch (§3) — it only
-- records it.
--
-- Safe to run repeatedly: every object uses IF NOT EXISTS. JSON columns are
-- JSONB; the TypeScript layer marshals them as parsed objects.
CREATE TABLE IF NOT EXISTS prometheus_generations (
id BIGSERIAL PRIMARY KEY,
-- The cleanup receipt this generation drew its burned-token context from.
-- Nullable: a generation can be seeded from a minimal operator-supplied input
-- with no backing receipt.
receipt_id BIGINT,
input_json JSONB NOT NULL,
output_json JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'generated'
CHECK (status IN ('generated', 'approved', 'rejected', 'launched')),
risk_flags_json JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
approved_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS spawn_records (
id BIGSERIAL PRIMARY KEY,
generation_id BIGINT NOT NULL REFERENCES prometheus_generations(id),
spawn_name TEXT NOT NULL,
ticker TEXT NOT NULL,
mint TEXT,
metadata_uri TEXT,
pumpfun_url TEXT,
launch_tx TEXT,
status TEXT NOT NULL DEFAULT 'launched'
CHECK (status IN ('launched', 'pending')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_prometheus_generations_receipt_id
ON prometheus_generations (receipt_id);
CREATE INDEX IF NOT EXISTS idx_spawn_records_generation_id
ON spawn_records (generation_id);

View File

@@ -341,6 +341,375 @@ export async function getEssenceSummary(): Promise<EssenceSummary> {
return { roundId, totalLamports, contributionCount, recent };
}
// ===========================================================================
// Prometheus generations + Spawn records (§10, §15) — manual Pump.fun workflow.
//
// The MVP Pump.fun launch is MANUAL / approval-gated: Prometheus generates a
// Spawn package (recorded in `prometheus_generations`), an operator reviews it
// and creates the token on Pump.fun by hand, then records the launch
// identifiers (`spawn_records`). PYRE never holds keys and never signs a launch
// (§3) — it only records what the operator did.
// ===========================================================================
/** Lifecycle status of a `prometheus_generations` row. */
export type GenerationStatus = "generated" | "approved" | "rejected" | "launched";
/** Input for {@link recordGeneration}. */
export interface RecordGenerationInput {
/**
* The `cleanup_receipts.id` this generation drew burned-token context from, or
* `null` when seeded from a minimal operator-supplied input.
*/
receiptId?: string | null;
/** The assembled Prometheus mixer input (persisted as JSONB). */
input: unknown;
/** The Prometheus generation output (persisted as JSONB). */
output: unknown;
/** Safety/compliance flags raised during generation. */
riskFlags: string[];
}
/** A persisted `prometheus_generations` row. */
export interface GenerationRecord {
id: string;
receiptId: string | null;
input: unknown;
output: unknown;
status: GenerationStatus;
riskFlags: string[];
createdAt: string;
approvedAt: string | null;
rejectedAt: string | null;
}
/** Input for {@link recordSpawnLaunch}. */
export interface RecordSpawnLaunchInput {
/** The `prometheus_generations.id` this Spawn was launched from. */
generationId: string;
spawnName: string;
ticker: string;
/** SPL mint address of the launched token (base58). */
mint?: string;
/** URI of the token metadata JSON. */
metadataUri?: string;
/** Public Pump.fun page for the token. */
pumpfunUrl?: string;
/** Confirmed launch (create) transaction signature. */
launchTx?: string;
}
/** A persisted `spawn_records` row. */
export interface SpawnRecordRow {
id: string;
generationId: string;
spawnName: string;
ticker: string;
mint: string | null;
metadataUri: string | null;
pumpfunUrl: string | null;
launchTx: string | null;
status: "launched" | "pending";
createdAt: string;
}
/**
* Burned-token context derived from a persisted cleanup receipt, used to seed a
* Prometheus generation when the operator supplies a `receiptId`.
*/
export interface ReceiptContext {
wallet: string;
kind: ReceiptKind;
/** Addresses of the accounts closed/burned by the receipt's transaction. */
closedAccounts: string[];
}
/** Postgres `to_char` format producing an ISO-8601 timestamp with offset. */
const ISO_TS = `'YYYY-MM-DD"T"HH24:MI:SS.MSOF'`;
/**
* Look up burned-token context from a persisted `cleanup_receipts` row by its id.
*
* Returns `null` when no such receipt exists. The `receiptId` is the receipt
* row's BIGSERIAL id (string form); non-numeric ids never match.
*/
export async function getReceiptContext(
receiptId: string,
): Promise<ReceiptContext | null> {
if (!/^\d+$/.test(receiptId)) return null;
const db = getPool();
const res = await db.query<{
wallet: string;
kind: string;
closed_accounts: unknown;
}>(
`SELECT wallet, kind, closed_accounts
FROM cleanup_receipts
WHERE id = $1::bigint`,
[receiptId],
);
const row = res.rows[0];
if (row === undefined) return null;
const closedAccounts = Array.isArray(row.closed_accounts)
? (row.closed_accounts as unknown[]).filter(
(v): v is string => typeof v === "string",
)
: [];
return {
wallet: row.wallet,
kind: row.kind as ReceiptKind,
closedAccounts,
};
}
/**
* Persist a Prometheus generation (the mixer input, the raw output, and the
* risk flags) and return the new row's id.
*
* Status starts at `'generated'`. JSON columns are stored as JSONB via
* parameterized `::jsonb` casts — never string-interpolated.
*/
export async function recordGeneration(
g: RecordGenerationInput,
): Promise<{ id: string }> {
const db = getPool();
const res = await db.query<{ id: string }>(
`INSERT INTO prometheus_generations
(receipt_id, input_json, output_json, risk_flags_json)
VALUES ($1::bigint, $2::jsonb, $3::jsonb, $4::jsonb)
RETURNING id::text AS id`,
[
g.receiptId != null && /^\d+$/.test(g.receiptId) ? g.receiptId : null,
JSON.stringify(g.input),
JSON.stringify(g.output),
JSON.stringify(g.riskFlags),
],
);
const row = res.rows[0];
if (row === undefined) throw new Error("failed to record generation");
return { id: row.id };
}
/** Fetch a single Prometheus generation by id, or `null` if it does not exist. */
export async function getGeneration(
id: string,
): Promise<GenerationRecord | null> {
if (!/^\d+$/.test(id)) return null;
const db = getPool();
const res = await db.query<{
id: string;
receipt_id: string | null;
input_json: unknown;
output_json: unknown;
status: string;
risk_flags_json: unknown;
created_at: string;
approved_at: string | null;
rejected_at: string | null;
}>(
`SELECT
id::text AS id,
receipt_id::text AS receipt_id,
input_json,
output_json,
status,
risk_flags_json,
to_char(created_at, ${ISO_TS}) AS created_at,
to_char(approved_at, ${ISO_TS}) AS approved_at,
to_char(rejected_at, ${ISO_TS}) AS rejected_at
FROM prometheus_generations
WHERE id = $1::bigint`,
[id],
);
const row = res.rows[0];
if (row === undefined) return null;
return {
id: row.id,
receiptId: row.receipt_id,
input: row.input_json,
output: row.output_json,
status: row.status as GenerationStatus,
riskFlags: Array.isArray(row.risk_flags_json)
? (row.risk_flags_json as unknown[]).filter(
(v): v is string => typeof v === "string",
)
: [],
createdAt: row.created_at,
approvedAt: row.approved_at,
rejectedAt: row.rejected_at,
};
}
/**
* Record a MANUAL Pump.fun launch: the operator created the token by hand and is
* now filing the immutable public record (mint / metadata URI / Pump.fun URL /
* launch tx). Marks the originating generation `'launched'` in the same
* transaction. Returns the new Spawn record.
*
* PYRE never signs the launch (§3) — these are recorded facts, not actions.
*/
export async function recordSpawnLaunch(
s: RecordSpawnLaunchInput,
): Promise<SpawnRecordRow> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const inserted = await client.query<{
id: string;
generation_id: string;
spawn_name: string;
ticker: string;
mint: string | null;
metadata_uri: string | null;
pumpfun_url: string | null;
launch_tx: string | null;
status: string;
created_at: string;
}>(
`INSERT INTO spawn_records
(generation_id, spawn_name, ticker, mint, metadata_uri, pumpfun_url, launch_tx, status)
VALUES ($1::bigint, $2, $3, $4, $5, $6, $7, 'launched')
RETURNING
id::text AS id,
generation_id::text AS generation_id,
spawn_name,
ticker,
mint,
metadata_uri,
pumpfun_url,
launch_tx,
status,
to_char(created_at, ${ISO_TS}) AS created_at`,
[
s.generationId,
s.spawnName,
s.ticker,
s.mint ?? null,
s.metadataUri ?? null,
s.pumpfunUrl ?? null,
s.launchTx ?? null,
],
);
const row = inserted.rows[0];
if (row === undefined) throw new Error("failed to record spawn launch");
// Mark the originating generation as launched (best-effort within the tx).
await client.query(
`UPDATE prometheus_generations SET status = 'launched' WHERE id = $1::bigint`,
[s.generationId],
);
await client.query("COMMIT");
return mapSpawnRow(row);
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
/** Map a raw `spawn_records` row to a {@link SpawnRecordRow}. */
function mapSpawnRow(row: {
id: string;
generation_id: string;
spawn_name: string;
ticker: string;
mint: string | null;
metadata_uri: string | null;
pumpfun_url: string | null;
launch_tx: string | null;
status: string;
created_at: string;
}): SpawnRecordRow {
return {
id: row.id,
generationId: row.generation_id,
spawnName: row.spawn_name,
ticker: row.ticker,
mint: row.mint,
metadataUri: row.metadata_uri,
pumpfunUrl: row.pumpfun_url,
launchTx: row.launch_tx,
status: row.status as "launched" | "pending",
createdAt: row.created_at,
};
}
/**
* List Spawn records, newest first. `limit` is clamped to 1..100 (default 50).
* Public / read-only.
*/
export async function listSpawns(limit = 50): Promise<SpawnRecordRow[]> {
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit) || 50));
const db = getPool();
const res = await db.query<{
id: string;
generation_id: string;
spawn_name: string;
ticker: string;
mint: string | null;
metadata_uri: string | null;
pumpfun_url: string | null;
launch_tx: string | null;
status: string;
created_at: string;
}>(
`SELECT
id::text AS id,
generation_id::text AS generation_id,
spawn_name,
ticker,
mint,
metadata_uri,
pumpfun_url,
launch_tx,
status,
to_char(created_at, ${ISO_TS}) AS created_at
FROM spawn_records
ORDER BY id DESC
LIMIT $1`,
[safeLimit],
);
return res.rows.map(mapSpawnRow);
}
/** Fetch a single Spawn record by id, or `null` if it does not exist. */
export async function getSpawnRecord(id: string): Promise<SpawnRecordRow | null> {
if (!/^\d+$/.test(id)) return null;
const db = getPool();
const res = await db.query<{
id: string;
generation_id: string;
spawn_name: string;
ticker: string;
mint: string | null;
metadata_uri: string | null;
pumpfun_url: string | null;
launch_tx: string | null;
status: string;
created_at: string;
}>(
`SELECT
id::text AS id,
generation_id::text AS generation_id,
spawn_name,
ticker,
mint,
metadata_uri,
pumpfun_url,
launch_tx,
status,
to_char(created_at, ${ISO_TS}) AS created_at
FROM spawn_records
WHERE id = $1::bigint`,
[id],
);
const row = res.rows[0];
if (row === undefined) return null;
return mapSpawnRow(row);
}
/**
* Close and clear the singleton pool. Intended for graceful shutdown / test
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.

View File

@@ -9,12 +9,13 @@
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "echo \"lint: ok (placeholder)\"",
"test": "echo \"test: ok (placeholder)\""
"test": "vitest run"
},
"dependencies": {
"@pyre/core": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^3.0.0"
}
}

View File

@@ -1,72 +1,473 @@
/**
* @pyre/prometheus — AI generation logic (STUBS ONLY).
* @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 §10 (Pump.fun Creator Workflow) of `docs/PYRE_MVP_DESIGN.md`.
* Mixer) and §16 (Security / AI safety) of `docs/PYRE_MVP_DESIGN.md`.
*
* Design notes:
* - Prometheus generates Spawn *identity* only — it never controls funds.
* - Meta influence is probabilistic, not deterministic.
* - Do not allow users to force exact copyrighted/existing meme identities;
* produce inspired mutations, not direct clones.
*
* TODO: the AI client (Anthropic / OpenAI / image-gen provider) is configured
* via `@pyre/config` and injected here — this package adds NO SDK dependencies
* of its own. Wire the client through function parameters or a small interface.
*
* Nothing here is implemented yet — every function throws "not implemented".
* - 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, PrometheusOutput } from "@pyre/core";
const NOT_IMPLEMENTED = "not implemented";
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
// ---------------------------------------------------------------------------
/**
* Build the text-generation prompt from mixer input.
*
* TODO: apply prompt templates and the probabilistic meta-influence weighting
* (burned archetypes / Essence themes / chaos mutation / operator seed).
* §9 influence budget. Weights sum to 100. Deterministic, not random — the
* "probabilistic" model is approximated by a fixed budget sampled via hashing.
*/
export function buildPrompt(_input: PrometheusInput): string {
throw new Error(NOT_IMPLEMENTED);
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))];
}
/**
* Run the meta mixer end-to-end: build prompt, call the AI client, parse, and
* run safety checks, producing a Spawn package.
*
* TODO: orchestrate buildPrompt -> AI client -> parseOutput -> runSafetyChecks.
* The AI client is injected (configured via @pyre/config).
* Deterministically pick up to `count` items from `pool`, seeded by `seed`.
* Stable for a given (pool, seed, count).
*/
export function runMetaMixer(_input: PrometheusInput): Promise<PrometheusOutput> {
throw new Error(NOT_IMPLEMENTED);
function pick(pool: string[], count: number, seed: number): string[] {
if (pool.length === 0 || count <= 0) return [];
const out: string[] = [];
const used = new Set<number>();
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;
}
/**
* Parse a raw model response into a structured `PrometheusOutput`.
*
* TODO: validate/normalize the model JSON into the output schema.
* 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 parseOutput(_raw: string): PrometheusOutput {
throw new Error(NOT_IMPLEMENTED);
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 : "";
}
/**
* Run safety/compliance checks over generated output.
*
* TODO: moderation + copyright/clone guards; return risk flags. Must reject
* attempts to clone exact copyrighted or existing meme identities.
* 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 runSafetyChecks(_output: PrometheusOutput): Promise<string[]> {
throw new Error(NOT_IMPLEMENTED);
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<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(text);
if (parsed && typeof parsed === "object") {
obj = parsed as Record<string, unknown>;
}
} 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[];
}
/**
* Generate an image prompt for the Spawn artwork.
*
* TODO: derive an image prompt from the Spawn identity, honoring the same
* anti-clone safety rules.
* Screen text against the local denylist and the injected `ModerationProvider`.
* Returns combined risk flags. Defaults to the offline stub moderation.
*/
export function generateImagePrompt(_output: PrometheusOutput): string {
throw new Error(NOT_IMPLEMENTED);
export async function runSafetyChecks(
text: string,
moderation: ModerationProvider = new StubModerationProvider(),
): Promise<SafetyResult> {
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<PrometheusGenerateResponse> {
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,
};
}

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import type { PrometheusInput } from "@pyre/core";
import {
buildPrompt,
generateSpawn,
parseOutput,
runMetaMixer,
runSafetyChecks,
StubTextProvider,
type TextProvider,
} from "./index.js";
const INPUT: PrometheusInput = {
burnedTokens: [
{ mint: "MintA1111111111111111111111111111111111111", symbol: "FROG", name: "Frog Coin" },
{ mint: "MintB2222222222222222222222222222222222222", symbol: "DOGE", name: "Doge Dead" },
],
transmutedTokens: [
{ mint: "MintC3333333333333333333333333333333333333", symbol: "WOJK", name: "Wojak" },
],
tokenSymbols: ["FROG", "DOGE", "WOJK"],
tokenNames: ["Frog Coin", "Doge Dead", "Wojak"],
metadataDescriptions: ["a sad frog", "much wow"],
dominantArchetypes: ["amphibian", "canine", "doomer"],
chaosFactor: 0.5,
manualThemeSeed: "rebirth",
};
describe("runMetaMixer", () => {
it("is deterministic: same input → same theme", () => {
const a = runMetaMixer(INPUT, "op-seed");
const b = runMetaMixer(INPUT, "op-seed");
expect(a).toEqual(b);
expect(a.seed).toBe(b.seed);
expect(a.keywords).toEqual(b.keywords);
});
it("changes the theme when the operator seed changes", () => {
const a = runMetaMixer(INPUT, "op-seed-1");
const b = runMetaMixer(INPUT, "op-seed-2");
expect(a.seed).not.toBe(b.seed);
});
it("uses the §9 influence budget summing to 100", () => {
const theme = runMetaMixer(INPUT);
const total = theme.influences.reduce((s, i) => s + i.weight, 0);
expect(total).toBe(100);
expect(theme.influences[0]?.source).toBe("burnedArchetypes");
});
});
describe("buildPrompt", () => {
it("contains the inspired-mutation (anti-clone) guard and forbids real people/brands", () => {
const prompt = buildPrompt(INPUT);
expect(prompt).toMatch(/INSPIRED MUTATION/i);
expect(prompt).toMatch(/NOT a clone/i);
expect(prompt).toMatch(/real people/i);
expect(prompt).toMatch(/trademark/i);
expect(prompt).toMatch(/STRICT JSON/i);
});
});
describe("runSafetyChecks", () => {
it("catches a denylisted term", async () => {
const res = await runSafetyChecks("This is a guaranteed returns nazi pepe token");
expect(res.flagged).toBe(true);
expect(res.riskFlags.some((f) => f.startsWith("denylist:"))).toBe(true);
});
it("passes clean text", async () => {
const res = await runSafetyChecks("Ashen Ember rises from the ash");
expect(res.flagged).toBe(false);
expect(res.riskFlags).toEqual([]);
});
it("merges moderation provider flags", async () => {
const res = await runSafetyChecks("totally fine text", {
check: async () => ({ flagged: true, categories: ["violence"] }),
});
expect(res.flagged).toBe(true);
expect(res.riskFlags).toContain("moderation:violence");
});
});
describe("parseOutput", () => {
it("parses strict JSON and normalizes the ticker", () => {
const out = parseOutput(
JSON.stringify({ name: "Ashen Ember", ticker: "ae-99!", lore: "x", tagline: "y", description: "z", imagePrompt: "p" }),
);
expect(out.name).toBe("Ashen Ember");
expect(out.ticker).toBe("AE99");
});
it("is defensive against junk / prose-wrapped JSON", () => {
const out = parseOutput('here you go: {"name":"X","ticker":"XX"} thanks');
expect(out.name).toBe("X");
expect(out.ticker).toBe("XX");
const bad = parseOutput("not json at all");
expect(bad.name).toBe("");
expect(bad.ticker).toBe("");
});
});
describe("generateSpawn", () => {
it("returns a valid package with name/ticker and a riskFlags array using stubs", async () => {
const res = await generateSpawn(INPUT);
expect(typeof res.generationId).toBe("string");
expect(res.generationId.length).toBeGreaterThan(0);
expect(res.spawnName.length).toBeGreaterThan(0);
expect(res.ticker.length).toBeGreaterThan(0);
expect(Array.isArray(res.riskFlags)).toBe(true);
expect(res.imagePrompt).toMatch(/STYLE GUARD/i);
expect(res.metadata["imageUrl"]).toMatch(/^stub:\/\/spawn\//);
expect(res.metadata["tagline"]).toBeDefined();
});
it("is deterministic in content for the same input (ignoring the random generationId)", async () => {
const a = await generateSpawn(INPUT, { operatorSeed: "x" });
const b = await generateSpawn(INPUT, { operatorSeed: "x" });
expect(a.spawnName).toBe(b.spawnName);
expect(a.ticker).toBe(b.ticker);
expect(a.imagePrompt).toBe(b.imagePrompt);
expect(a.generationId).not.toBe(b.generationId);
});
it("surfaces risk flags when generation produces a denylisted term", async () => {
const badProvider: TextProvider = {
generate: async () =>
JSON.stringify({
name: "Pepe Clone",
ticker: "PEPE",
lore: "guaranteed returns",
tagline: "t",
description: "d",
imagePrompt: "p",
}),
};
const res = await generateSpawn(INPUT, { text: badProvider });
expect(res.riskFlags.length).toBeGreaterThan(0);
});
it("the default stub text provider yields JSON the parser accepts", async () => {
const stub = new StubTextProvider();
const parsed = parseOutput(await stub.generate(buildPrompt(INPUT)));
expect(parsed.name.length).toBeGreaterThan(0);
expect(parsed.ticker.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,170 @@
/**
* Provider abstractions for Prometheus generation (§9, §16).
*
* Generation is PROVIDER-ABSTRACTED so the engine runs with NO network/keys
* today. The Stub* implementations are deterministic and offline. Real
* providers slot in later behind the same interfaces — this package adds NO
* SDK dependencies of its own (clients are injected, configured via
* `@pyre/config`).
*/
import type { PrometheusInput } from "@pyre/core";
/** Text generation: returns a JSON string the parser validates. */
export interface TextProvider {
generate(prompt: string): Promise<string>;
}
/** Image generation: returns a URL and/or base64 image payload. */
export interface ImageProvider {
generate(prompt: string): Promise<{ url?: string; b64?: string }>;
}
/** Content moderation: classifies text and returns flagged categories. */
export interface ModerationProvider {
check(text: string): Promise<{ flagged: boolean; categories: string[] }>;
}
// ---------------------------------------------------------------------------
// Deterministic, offline stub providers
// ---------------------------------------------------------------------------
/**
* Tiny non-cryptographic 32-bit hash (FNV-1a variant). Deterministic and
* dependency-free — used to derive stable pseudo-values from input text so the
* stubs need no `Math.random` (unavailable per design).
*/
export function hashString(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
// Force unsigned 32-bit.
return h >>> 0;
}
/** Stable hex digest of `value` (8 hex chars). */
export function hashHex(value: string): string {
return hashString(value).toString(16).padStart(8, "0");
}
/**
* Deterministic offline text provider.
*
* Returns a plausible JSON `{ name, ticker, lore, tagline, description }`
* derived from the prompt. The prompt embeds the meta-mixer theme (see
* `buildPrompt`), so output is stable for a given input.
*
* REAL provider to wire later — Claude Haiku 4.5 structured output:
* const res = await anthropic.messages.create({
* model: "claude-haiku-4-5",
* max_tokens: 1024,
* system: "Return ONLY strict JSON matching the schema.",
* messages: [{ role: "user", content: prompt }],
* // tool/response_format-style structured output enforcing the schema
* });
* return res.content[0].text; // JSON string
*/
export class StubTextProvider implements TextProvider {
// eslint-disable-next-line @typescript-eslint/require-await
async generate(prompt: string): Promise<string> {
const seed = hashString(prompt);
// Derive a couple of plausible word fragments deterministically.
const adjectives = [
"Ashen",
"Molten",
"Spectral",
"Feral",
"Gilded",
"Obsidian",
"Riftborn",
"Vagrant",
];
const nouns = [
"Ember",
"Hydra",
"Wraith",
"Cinder",
"Golem",
"Sigil",
"Phoenix",
"Maw",
];
const adj = adjectives[seed % adjectives.length] ?? "Ashen";
const noun = nouns[(seed >>> 3) % nouns.length] ?? "Ember";
const name = `${adj} ${noun}`;
const ticker = `${adj[0] ?? "A"}${noun.slice(0, 3)}`.toUpperCase();
const payload = {
name,
ticker,
lore:
`Forged from burned remnants, the ${name} rose from the PYRE — an ` +
`inspired mutation of dead memes, never a clone. Its sigil glows ${hashHex(
prompt,
)}.`,
tagline: `Burn the dead. Claim the ${noun}.`,
description:
`${name} (${ticker}) is an entertainment Spawn born of ritual ` +
`incineration. No promises, no real-world likeness — only chaos reforged.`,
};
return JSON.stringify(payload);
}
}
/**
* Deterministic offline image provider. Returns a stub URL keyed by a hash of
* the prompt so identical prompts map to identical URLs.
*
* REAL provider to wire later — FLUX.1 [schnell] via Replicate (returns a URL):
* const out = await replicate.run("black-forest-labs/flux-schnell", {
* input: { prompt, num_outputs: 1, aspect_ratio: "1:1" },
* });
* return { url: Array.isArray(out) ? out[0] : out };
*/
export class StubImageProvider implements ImageProvider {
// eslint-disable-next-line @typescript-eslint/require-await
async generate(prompt: string): Promise<{ url?: string; b64?: string }> {
return { url: `stub://spawn/${hashHex(prompt)}` };
}
}
/**
* Deterministic offline moderation provider. Always returns not-flagged; the
* engine's local denylist (`runSafetyChecks`) is the real offline guard.
*
* REAL provider to wire later — OpenAI omni-moderation (free):
* const res = await openai.moderations.create({
* model: "omni-moderation-latest",
* input: text,
* });
* const r = res.results[0];
* return {
* flagged: r.flagged,
* categories: Object.entries(r.categories)
* .filter(([, v]) => v)
* .map(([k]) => k),
* };
*/
export class StubModerationProvider implements ModerationProvider {
// eslint-disable-next-line @typescript-eslint/require-await
async check(_text: string): Promise<{ flagged: boolean; categories: string[] }> {
return { flagged: false, categories: [] };
}
}
/** Convenience: derive a deterministic numeric seed from a Prometheus input. */
export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number {
const parts = [
...input.burnedTokens.map((t) => `${t.mint}:${t.symbol ?? ""}:${t.name ?? ""}`),
...input.transmutedTokens.map((t) => `${t.mint}:${t.symbol ?? ""}:${t.name ?? ""}`),
...input.tokenSymbols,
...input.tokenNames,
...input.metadataDescriptions,
...input.dominantArchetypes,
String(input.chaosFactor),
input.manualThemeSeed ?? "",
operatorSeed ?? "",
];
return hashString(parts.join("|"));
}