From 8b58faf7c1b79576403acb9d2181a5fc7b18dcfa Mon Sep 17 00:00:00 2001 From: RogueWave Date: Sun, 31 May 2026 07:09:53 +0000 Subject: [PATCH] feat(prometheus+spawn): Prometheus engine (stubbed) + manual Pump.fun creator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/package.json | 1 + apps/api/src/index.ts | 350 +++++++++++++++ apps/web/src/app/globals.css | 115 +++++ apps/web/src/app/spawn/page.tsx | 144 ++++++ apps/web/src/components/Footer.tsx | 1 + infra/status/index.html | 38 +- infra/status/status.json | 24 +- packages/core/src/index.ts | 1 + packages/core/src/spawn.ts | 51 +++ packages/db/migrations/002_spawns.sql | 48 ++ packages/db/src/index.ts | 369 ++++++++++++++++ packages/prometheus/package.json | 5 +- packages/prometheus/src/index.ts | 487 +++++++++++++++++++-- packages/prometheus/src/prometheus.test.ts | 148 +++++++ packages/prometheus/src/providers.ts | 170 +++++++ pnpm-lock.yaml | 6 + 16 files changed, 1882 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/app/spawn/page.tsx create mode 100644 packages/core/src/spawn.ts create mode 100644 packages/db/migrations/002_spawns.sql create mode 100644 packages/prometheus/src/prometheus.test.ts create mode 100644 packages/prometheus/src/providers.ts diff --git a/apps/api/package.json b/apps/api/package.json index fd89dec..ad4bcca 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@pyre/config": "workspace:*", "@pyre/core": "workspace:*", "@pyre/db": "workspace:*", + "@pyre/prometheus": "workspace:*", "@pyre/solana": "workspace:*", "@solana/web3.js": "^1.98.0", "bullmq": "^5.34.0", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 67da3b4..4aa06c3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,11 +49,74 @@ import { recordReceipt, recordEssence, getEssenceSummary, + recordGeneration, + getGeneration, + getReceiptContext, + recordSpawnLaunch, + listSpawns, + getSpawnRecord, } from "@pyre/db"; +import type { SpawnRecordRow } from "@pyre/db"; +import type { + PrometheusInput, + PrometheusGenerateResponse, + SpawnRecord, +} from "@pyre/core"; +// Prometheus owns generation (built in parallel). We code to its published +// signature: `generateSpawn(input, opts?): Promise`. +// If the only typecheck error is this not-yet-exported symbol, that is expected. +import { generateSpawn } from "@pyre/prometheus"; import { getSellQuote, getShield } from "./jupiter.js"; +/** + * Options accepted by `@pyre/prometheus`'s `generateSpawn`. Declared locally to + * match the published signature without depending on its internal types. + */ +interface GenerateSpawnOpts { + /** Chaos factor 0..1 controlling mutation strength. */ + chaos?: number; + /** Optional manual operator theme seed. */ + operatorSeed?: string; +} + const config = loadConfig(); +// --------------------------------------------------------------------------- +// Admin gate (§16): admin endpoints require an `x-admin-token` header equal to +// the configured `adminApiToken`. When the token is unset (empty), admin +// endpoints are CLOSED — they return 403 "admin not configured" rather than +// silently accepting everything. +// --------------------------------------------------------------------------- + +/** + * Authorize an admin request. Returns `null` when allowed, or an error payload + * (with the HTTP status) to send back when not. Uses a length-checked constant + * comparison to avoid leaking the token length via early-exit timing. + */ +function adminGate( + headerToken: string | string[] | undefined, +): { status: number; body: { error: string } } | null { + const expected = config.adminApiToken; + if (expected === "") { + return { status: 403, body: { error: "admin not configured" } }; + } + const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken; + if (typeof provided !== "string" || !timingSafeEqualStr(provided, expected)) { + return { status: 403, body: { error: "forbidden" } }; + } + return null; +} + +/** Constant-time string comparison (avoids early-exit timing side channels). */ +function timingSafeEqualStr(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + // --------------------------------------------------------------------------- // Sell-quote enrichment guards (READ-ONLY: quotes + risk flags only). // --------------------------------------------------------------------------- @@ -1040,6 +1103,293 @@ app.get("/api/essence", async (request) => { } }); +// =========================================================================== +// Prometheus / manual Pump.fun creator workflow (§10) — MVP is MANUAL. +// +// Flow: operator calls POST /api/prometheus/generate (ADMIN) to produce a Spawn +// package -> reviews it -> creates the token on Pump.fun BY HAND in their own +// wallet -> records the launch via POST /api/spawn/launch (ADMIN). The public +// then reads the immutable record via GET /api/spawns and GET /api/spawn/:id. +// +// PYRE never holds keys and never signs a launch (§3, §16). There is NO +// auto-create / PumpPortal path in the MVP. +// +// FUTURE (semi-automated, §10) — NOT built here, documented stub: +// Once a launch key/multisig is configured, a future POST /api/spawn/create +// would: (1) upload the approved metadata JSON to IPFS/Arweave, (2) build an +// UNSIGNED Pump.fun create transaction, (3) return it for the operator's +// creator/multisig wallet to sign (e.g. via PumpPortal local-tx mode), and +// (4) record the confirmed launch exactly as POST /api/spawn/launch does. +// That path still never custodially signs — PYRE only ever builds + records. +// =========================================================================== + +/** Map a persisted Spawn row to the public `SpawnRecord` DTO. */ +function toSpawnDto(row: SpawnRecordRow): SpawnRecord { + return { + id: row.id, + generationId: row.generationId, + spawnName: row.spawnName, + ticker: row.ticker, + mint: row.mint ?? undefined, + metadataUri: row.metadataUri ?? undefined, + pumpfunUrl: row.pumpfunUrl ?? undefined, + launchTx: row.launchTx ?? undefined, + status: row.status, + createdAt: row.createdAt, + }; +} + +/** + * Request body schema for POST /api/prometheus/generate. + * + * Either seed from a persisted receipt (`receiptId`) or supply a minimal input + * directly. `chaos` is bounded 0..1; `operatorSeed` is the §9 manual theme seed. + */ +const generateBodySchema = { + type: "object", + additionalProperties: false, + properties: { + receiptId: { type: "string", minLength: 1, maxLength: 64 }, + chaos: { type: "number", minimum: 0, maximum: 1 }, + operatorSeed: { type: "string", maxLength: 280 }, + // Minimal direct input used when no receiptId is given. + tokenSymbols: { type: "array", maxItems: 50, items: { type: "string", maxLength: 32 } }, + tokenNames: { type: "array", maxItems: 50, items: { type: "string", maxLength: 64 } }, + }, +} as const; + +interface GenerateBody { + receiptId?: string; + chaos?: number; + operatorSeed?: string; + tokenSymbols?: string[]; + tokenNames?: string[]; +} + +/** + * POST /api/prometheus/generate (ADMIN) — generate a Spawn package. + * + * in: { receiptId?, chaos?, operatorSeed?, tokenSymbols?, tokenNames? } + * out: PrometheusGenerateResponse + * + * Assembles a PrometheusInput (deriving burned-token context from the persisted + * receipt when `receiptId` is given, else from a minimal body), calls + * `generateSpawn(input, { chaos, operatorSeed })`, persists the generation, and + * returns the response. Admin-gated + rate-limited. Best-effort DB: a + * persistence failure is logged and does not fail the generation. + */ +app.post<{ Body: GenerateBody }>( + "/api/prometheus/generate", + { + schema: { body: generateBodySchema }, + config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const gate = adminGate(request.headers["x-admin-token"]); + if (gate) return reply.code(gate.status).send(gate.body); + + const { receiptId, chaos, operatorSeed, tokenSymbols, tokenNames } = + request.body; + + // Assemble the mixer input. Start from the minimal body, then enrich from a + // persisted receipt's burned-token context when a receiptId is supplied. + const burnedSymbols = tokenSymbols ?? []; + const burnedNames = tokenNames ?? []; + let burnedMints: string[] = []; + + if (receiptId !== undefined) { + let ctx; + try { + ctx = await getReceiptContext(receiptId); + } catch (err) { + request.log.warn({ err, receiptId }, "receipt context lookup failed"); + return reply.code(502).send({ error: "receipt lookup failed" }); + } + if (ctx === null) { + return reply.code(404).send({ error: "receipt not found" }); + } + // closedAccounts are token-account addresses; surface them as mint hints. + burnedMints = ctx.closedAccounts; + } + + const input: PrometheusInput = { + burnedTokens: burnedMints.map((mint) => ({ mint })), + transmutedTokens: [], + tokenSymbols: burnedSymbols, + tokenNames: burnedNames, + metadataDescriptions: [], + dominantArchetypes: [], + chaosFactor: typeof chaos === "number" ? chaos : 0.2, + ...(operatorSeed !== undefined ? { manualThemeSeed: operatorSeed } : {}), + }; + + const opts: GenerateSpawnOpts = {}; + if (typeof chaos === "number") opts.chaos = chaos; + if (operatorSeed !== undefined) opts.operatorSeed = operatorSeed; + + let result: PrometheusGenerateResponse; + try { + result = await generateSpawn(input, opts); + } catch (err) { + request.log.error({ err }, "Prometheus generation failed"); + return reply.code(502).send({ error: "generation failed" }); + } + + // Persist best-effort — a DB outage must not discard a fresh generation. + try { + await recordGeneration({ + receiptId: receiptId ?? null, + input, + output: result, + riskFlags: result.riskFlags, + }); + } catch (err) { + request.log.warn({ err }, "generation persistence failed (best-effort)"); + } + + return result; + }, +); + +/** + * Request body schema for POST /api/spawn/launch. The operator records a MANUAL + * Pump.fun creation: `generationId` is required; launch identifiers are recorded + * verbatim. `additionalProperties:false` so nothing else is smuggled in. + */ +const spawnLaunchBodySchema = { + type: "object", + required: ["generationId"], + additionalProperties: false, + properties: { + generationId: { type: "string", minLength: 1, maxLength: 64 }, + mint: { type: "string", minLength: 32, maxLength: 44 }, + metadataUri: { type: "string", maxLength: 512 }, + pumpfunUrl: { type: "string", maxLength: 512 }, + launchTx: { type: "string", maxLength: 128 }, + }, +} as const; + +interface SpawnLaunchBody { + generationId: string; + mint?: string; + metadataUri?: string; + pumpfunUrl?: string; + launchTx?: string; +} + +/** + * POST /api/spawn/launch (ADMIN) — record a MANUAL Pump.fun creation. + * + * in: { generationId, mint?, metadataUri?, pumpfunUrl?, launchTx? } + * out: SpawnRecord + * + * This records what the operator did by hand on Pump.fun (PYRE never signs the + * launch, §3). Admin-gated. Returns 404 when the generation does not exist. + */ +app.post<{ Body: SpawnLaunchBody }>( + "/api/spawn/launch", + { + schema: { body: spawnLaunchBodySchema }, + config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const gate = adminGate(request.headers["x-admin-token"]); + if (gate) return reply.code(gate.status).send(gate.body); + + const { generationId, mint, metadataUri, pumpfunUrl, launchTx } = + request.body; + + // Validate the mint pubkey when supplied (base58) — never trust raw input. + if (mint !== undefined) { + try { + new PublicKey(mint); + } catch { + return reply.code(400).send({ error: "invalid mint address" }); + } + } + + // The generation must exist (and so seeds the record's name/ticker). + let generation; + try { + generation = await getGenerationOrNull(generationId); + } catch (err) { + request.log.error({ err, generationId }, "generation lookup failed"); + return reply.code(502).send({ error: "generation lookup failed" }); + } + if (generation === null) { + return reply.code(404).send({ error: "generation not found" }); + } + + let row: SpawnRecordRow; + try { + row = await recordSpawnLaunch({ + generationId, + spawnName: generation.spawnName, + ticker: generation.ticker, + mint, + metadataUri, + pumpfunUrl, + launchTx, + }); + } catch (err) { + request.log.error({ err, generationId }, "spawn launch record failed"); + return reply.code(502).send({ error: "could not record launch" }); + } + + return toSpawnDto(row); + }, +); + +/** + * Look up a generation's display name/ticker from its persisted output_json. + * Returns `null` when the generation does not exist. + */ +async function getGenerationOrNull( + id: string, +): Promise<{ spawnName: string; ticker: string } | null> { + const gen = await getGeneration(id); + if (gen === null) return null; + const out = (gen.output ?? {}) as Partial; + return { + spawnName: typeof out.spawnName === "string" ? out.spawnName : "Unnamed Spawn", + ticker: typeof out.ticker === "string" ? out.ticker : "SPAWN", + }; +} + +/** + * GET /api/spawns — public list of Spawn records (newest first). + * + * Read-only and public. Degrades gracefully: on any DB error it returns an + * empty list (200) so the public page always has something to render. + */ +app.get("/api/spawns", async (request) => { + try { + const rows = await listSpawns(50); + return { spawns: rows.map(toSpawnDto) }; + } catch (err) { + request.log.warn({ err }, "spawn list unavailable (DB error)"); + return { spawns: [] as SpawnRecord[] }; + } +}); + +/** + * GET /api/spawn/:id — public single Spawn record. 404 when not found. + */ +app.get<{ Params: { id: string } }>( + "/api/spawn/:id", + async (request, reply) => { + let row: SpawnRecordRow | null; + try { + row = await getSpawnRecord(request.params.id); + } catch (err) { + request.log.warn({ err }, "spawn record unavailable (DB error)"); + return reply.code(502).send({ error: "spawn lookup failed" }); + } + if (row === null) return reply.code(404).send({ error: "spawn not found" }); + return toSpawnDto(row); + }, +); + // Run DB migrations at startup. Best-effort: if the database is unreachable we // log a warning and keep serving — persistence (receipts + Essence ledger) // simply degrades to a no-op until the DB returns, rather than crashing the API. diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3c0f5d8..f0f00e7 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -942,3 +942,118 @@ body { background: var(--color-coal) !important; border: 1px solid var(--color-ember) !important; } + +/* ---- Public Spawn record page (/spawn) ---- */ +.spawn-page { + position: relative; + text-align: center; +} +.spawn-page__glow { + position: absolute; + inset: -2rem -2rem auto; + height: 8rem; + background: radial-gradient(60% 100% at 50% 0, rgba(255, 87, 34, 0.18), transparent 70%); + pointer-events: none; +} +.spawn-page__intro { + max-width: 40rem; + margin: 0 auto 2.5rem; + color: var(--color-smoke); + font-size: 0.95rem; + line-height: 1.7; +} +.spawn-page__note, +.spawn-page__empty { + color: var(--color-smoke); + font-size: 0.95rem; + margin: 2rem 0; +} +.spawn-page__empty { + color: var(--color-ember-bright); + font-weight: 600; +} + +.spawn-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1.25rem; + text-align: left; +} +.spawn-card { + background: var(--color-coal); + border: 1px solid rgba(255, 87, 34, 0.25); + border-radius: 0.9rem; + overflow: hidden; + display: flex; + flex-direction: column; +} +.spawn-card__img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} +.spawn-card__img--placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + background: radial-gradient(80% 80% at 50% 30%, rgba(255, 87, 34, 0.2), var(--color-ash)); +} +.spawn-card__body { + padding: 1rem 1.1rem 1.2rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.spawn-card__name { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: #f5ede6; +} +.spawn-card__ticker { + color: var(--color-ember-bright); + font-weight: 700; + font-size: 0.9rem; +} +.spawn-card__lore { + margin: 0; + color: var(--color-smoke); + font-size: 0.85rem; + line-height: 1.55; +} +.spawn-card__meta { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; +} +.spawn-card__row { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} +.spawn-card__row dt { + color: var(--color-smoke); +} +.spawn-card__row dd { + margin: 0; + color: #f5ede6; + font-variant-numeric: tabular-nums; +} +.spawn-card__link { + margin-top: 0.3rem; + align-self: flex-start; + color: var(--color-ember-bright); + font-weight: 600; + font-size: 0.85rem; + text-decoration: none; +} +.spawn-card__link:hover { + text-decoration: underline; +} diff --git a/apps/web/src/app/spawn/page.tsx b/apps/web/src/app/spawn/page.tsx new file mode 100644 index 0000000..d014ab6 --- /dev/null +++ b/apps/web/src/app/spawn/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Footer } from "../../components/Footer"; + +// Same-origin by default so production hits "/api/spawns" behind the same host. +// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev). +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +/** Public Spawn record shape, mirroring `@pyre/core`'s `SpawnRecord`. */ +type SpawnRecord = { + id: string; + generationId: string; + spawnName: string; + ticker: string; + mint?: string; + metadataUri?: string; + pumpfunUrl?: string; + launchTx?: string; + status: "launched" | "pending"; + createdAt: string; + imageUrl?: string; + lore?: string; +}; + +type SpawnsResponse = { spawns: SpawnRecord[] }; + +function truncate(addr: string): string { + if (addr.length <= 10) return addr; + return `${addr.slice(0, 4)}…${addr.slice(-4)}`; +} + +/** + * Public, read-only Spawn record page (§10, §18 Phase 5). Lists Spawns whose + * tokens were MANUALLY created on Pump.fun by the operator and recorded here. + * No investment, yield, or profit is implied — ritual/entertainment framing. + */ +export default function SpawnPage() { + const [spawns, setSpawns] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let active = true; + (async () => { + try { + const res = await fetch(`${API_BASE}/api/spawns`); + if (!res.ok) throw new Error(`spawns fetch failed (${res.status})`); + const data = (await res.json()) as SpawnsResponse; + if (active) { + setSpawns(data.spawns ?? []); + setFailed(false); + } + } catch { + if (active) { + setSpawns([]); + setFailed(true); + } + } + })(); + return () => { + active = false; + }; + }, []); + + const loading = spawns === null && !failed; + const empty = !loading && (spawns === null || spawns.length === 0); + + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 1076225..3125981 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -21,6 +21,7 @@ export function Footer() { Repository Scanner + The Spawn

diff --git a/infra/status/index.html b/infra/status/index.html index d940a24..ebcf6fa 100644 --- a/infra/status/index.html +++ b/infra/status/index.html @@ -147,10 +147,10 @@

Overall MVP Progress

- 60% + 74%
-
-

32 of 53 phase deliverables complete

+
+

39 of 53 phase deliverables complete

Development Phases

@@ -219,33 +219,33 @@
  • Live signed burn verified e2e (mainnet)
  • -
    +

    Phase 4 Prometheus Generator

    - TODO + IN PROGRESS
    -

    0 / 6 complete

    +

    4 / 6 complete

      +
    • Meta mixer (deterministic influence model)
    • +
    • Spawn name/ticker/lore generation (provider-abstracted)
    • +
    • Image prompt generation
    • +
    • Safety checks (denylist + moderation)
    • Generation input from receipt
    • -
    • Meta mixer
    • -
    • Spawn name/ticker/lore generation
    • -
    • Image prompt generation
    • -
    • Safety checks
    • -
    • Admin approval UI
    • +
    • Wire real providers (keys) + admin approval UI
    -
    +

    Phase 5 Manual Pump.fun Launch Workflow

    - TODO + IN PROGRESS
    -

    0 / 5 complete

    +

    3 / 5 complete

      -
    • Approved Spawn package
    • -
    • Metadata JSON
    • -
    • Operator launch checklist
    • -
    • Mint/url/tx record input
    • -
    • Public Spawn record page
    • +
    • Spawn records DB + admin generate/launch endpoints
    • +
    • Mint/url/tx record input (admin)
    • +
    • Public Spawn record page (/spawn)
    • +
    • Metadata JSON + IPFS upload
    • +
    • Operator launch checklist / semi-auto create
    diff --git a/infra/status/status.json b/infra/status/status.json index 38a9feb..86b210e 100644 --- a/infra/status/status.json +++ b/infra/status/status.json @@ -64,26 +64,26 @@ { "id": 4, "name": "Prometheus Generator", - "state": "todo", + "state": "in_progress", "items": [ + { "label": "Meta mixer (deterministic influence model)", "done": true }, + { "label": "Spawn name/ticker/lore generation (provider-abstracted)", "done": true }, + { "label": "Image prompt generation", "done": true }, + { "label": "Safety checks (denylist + moderation)", "done": true }, { "label": "Generation input from receipt", "done": false }, - { "label": "Meta mixer", "done": false }, - { "label": "Spawn name/ticker/lore generation", "done": false }, - { "label": "Image prompt generation", "done": false }, - { "label": "Safety checks", "done": false }, - { "label": "Admin approval UI", "done": false } + { "label": "Wire real providers (keys) + admin approval UI", "done": false } ] }, { "id": 5, "name": "Manual Pump.fun Launch Workflow", - "state": "todo", + "state": "in_progress", "items": [ - { "label": "Approved Spawn package", "done": false }, - { "label": "Metadata JSON", "done": false }, - { "label": "Operator launch checklist", "done": false }, - { "label": "Mint/url/tx record input", "done": false }, - { "label": "Public Spawn record page", "done": false } + { "label": "Spawn records DB + admin generate/launch endpoints", "done": true }, + { "label": "Mint/url/tx record input (admin)", "done": true }, + { "label": "Public Spawn record page (/spawn)", "done": true }, + { "label": "Metadata JSON + IPFS upload", "done": false }, + { "label": "Operator launch checklist / semi-auto create", "done": false } ] }, { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f32265e..d8365e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from "./fee"; export * from "./sell"; export * from "./receipt"; export * from "./prometheus"; +export * from "./spawn"; diff --git a/packages/core/src/spawn.ts b/packages/core/src/spawn.ts new file mode 100644 index 0000000..7d272bc --- /dev/null +++ b/packages/core/src/spawn.ts @@ -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; +} diff --git a/packages/db/migrations/002_spawns.sql b/packages/db/migrations/002_spawns.sql new file mode 100644 index 0000000..1fff669 --- /dev/null +++ b/packages/db/migrations/002_spawns.sql @@ -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); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 43a0f4f..88010d4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -341,6 +341,375 @@ export async function getEssenceSummary(): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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. diff --git a/packages/prometheus/package.json b/packages/prometheus/package.json index 1814e25..31ddd24 100644 --- a/packages/prometheus/package.json +++ b/packages/prometheus/package.json @@ -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" } } diff --git a/packages/prometheus/src/index.ts b/packages/prometheus/src/index.ts index 834a716..e636383 100644 --- a/packages/prometheus/src/index.ts +++ b/packages/prometheus/src/index.ts @@ -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 { - 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(); + 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 { - 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 = {}; + 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[]; } /** - * 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 { + 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, + }; } diff --git a/packages/prometheus/src/prometheus.test.ts b/packages/prometheus/src/prometheus.test.ts new file mode 100644 index 0000000..22437bf --- /dev/null +++ b/packages/prometheus/src/prometheus.test.ts @@ -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); + }); +}); diff --git a/packages/prometheus/src/providers.ts b/packages/prometheus/src/providers.ts new file mode 100644 index 0000000..809690d --- /dev/null +++ b/packages/prometheus/src/providers.ts @@ -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; +} + +/** 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 { + 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("|")); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 049d018..21aafa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@pyre/db': specifier: workspace:* version: link:../../packages/db + '@pyre/prometheus': + specifier: workspace:* + version: link:../../packages/prometheus '@pyre/solana': specifier: workspace:* version: link:../../packages/solana @@ -181,6 +184,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) packages/solana: dependencies: