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:
@@ -17,6 +17,7 @@
|
|||||||
"@pyre/config": "workspace:*",
|
"@pyre/config": "workspace:*",
|
||||||
"@pyre/core": "workspace:*",
|
"@pyre/core": "workspace:*",
|
||||||
"@pyre/db": "workspace:*",
|
"@pyre/db": "workspace:*",
|
||||||
|
"@pyre/prometheus": "workspace:*",
|
||||||
"@pyre/solana": "workspace:*",
|
"@pyre/solana": "workspace:*",
|
||||||
"@solana/web3.js": "^1.98.0",
|
"@solana/web3.js": "^1.98.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
|
|||||||
@@ -49,11 +49,74 @@ import {
|
|||||||
recordReceipt,
|
recordReceipt,
|
||||||
recordEssence,
|
recordEssence,
|
||||||
getEssenceSummary,
|
getEssenceSummary,
|
||||||
|
recordGeneration,
|
||||||
|
getGeneration,
|
||||||
|
getReceiptContext,
|
||||||
|
recordSpawnLaunch,
|
||||||
|
listSpawns,
|
||||||
|
getSpawnRecord,
|
||||||
} from "@pyre/db";
|
} 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<PrometheusGenerateResponse>`.
|
||||||
|
// 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";
|
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();
|
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).
|
// 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<PrometheusGenerateResponse>;
|
||||||
|
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
|
// Run DB migrations at startup. Best-effort: if the database is unreachable we
|
||||||
// log a warning and keep serving — persistence (receipts + Essence ledger)
|
// 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.
|
// simply degrades to a no-op until the DB returns, rather than crashing the API.
|
||||||
|
|||||||
@@ -942,3 +942,118 @@ body {
|
|||||||
background: var(--color-coal) !important;
|
background: var(--color-coal) !important;
|
||||||
border: 1px solid var(--color-ember) !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;
|
||||||
|
}
|
||||||
|
|||||||
144
apps/web/src/app/spawn/page.tsx
Normal file
144
apps/web/src/app/spawn/page.tsx
Normal file
@@ -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<SpawnRecord[] | null>(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 (
|
||||||
|
<main className="page">
|
||||||
|
<section className="spawn-page" aria-labelledby="spawn-heading">
|
||||||
|
<div className="spawn-page__glow" aria-hidden="true" />
|
||||||
|
<h1 className="section-heading" id="spawn-heading">
|
||||||
|
The Spawn
|
||||||
|
</h1>
|
||||||
|
<p className="spawn-page__intro">
|
||||||
|
Tokens reborn from burned remnants — generated by Prometheus, reviewed
|
||||||
|
by hand, and created on Pump.fun by the operator. This is a public
|
||||||
|
record, not an investment. Entertainment only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="spawn-page__note">Reading the embers…</p>
|
||||||
|
) : empty ? (
|
||||||
|
<p className="spawn-page__empty">no Spawns yet — feed the PYRE 🔥</p>
|
||||||
|
) : (
|
||||||
|
<ul className="spawn-list">
|
||||||
|
{spawns!.map((s) => (
|
||||||
|
<li className="spawn-card" key={s.id}>
|
||||||
|
{s.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
className="spawn-card__img"
|
||||||
|
src={s.imageUrl}
|
||||||
|
alt={`${s.spawnName} artwork`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="spawn-card__img spawn-card__img--placeholder" aria-hidden="true">
|
||||||
|
🔥
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="spawn-card__body">
|
||||||
|
<h2 className="spawn-card__name">
|
||||||
|
{s.spawnName}{" "}
|
||||||
|
<span className="spawn-card__ticker">${s.ticker}</span>
|
||||||
|
</h2>
|
||||||
|
{s.lore ? <p className="spawn-card__lore">{s.lore}</p> : null}
|
||||||
|
<dl className="spawn-card__meta">
|
||||||
|
{s.mint ? (
|
||||||
|
<div className="spawn-card__row">
|
||||||
|
<dt>Mint</dt>
|
||||||
|
<dd title={s.mint}>{truncate(s.mint)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="spawn-card__row">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{s.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{s.pumpfunUrl ? (
|
||||||
|
<a
|
||||||
|
className="spawn-card__link"
|
||||||
|
href={s.pumpfunUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
View on Pump.fun ↗
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="spawn-page__note">
|
||||||
|
The Spawn record is resting. Try again shortly.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export function Footer() {
|
|||||||
Repository
|
Repository
|
||||||
</a>
|
</a>
|
||||||
<a href="#scanner">Scanner</a>
|
<a href="#scanner">Scanner</a>
|
||||||
|
<a href="/spawn">The Spawn</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p className="footer__disclaimer">
|
<p className="footer__disclaimer">
|
||||||
|
|||||||
@@ -147,10 +147,10 @@
|
|||||||
<section class="overall">
|
<section class="overall">
|
||||||
<div class="overall-head">
|
<div class="overall-head">
|
||||||
<h2>Overall MVP Progress</h2>
|
<h2>Overall MVP Progress</h2>
|
||||||
<span class="overall-pct">60%</span>
|
<span class="overall-pct">74%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar"><span style="width: 60%"></span></div>
|
<div class="bar"><span style="width: 74%"></span></div>
|
||||||
<p class="count">32 of 53 phase deliverables complete</p>
|
<p class="count">39 of 53 phase deliverables complete</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="section">Development Phases</h2>
|
<h2 class="section">Development Phases</h2>
|
||||||
@@ -219,33 +219,33 @@
|
|||||||
<li class="item done"><span class="mark">✓</span><span>Live signed burn verified e2e (mainnet)</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Live signed burn verified e2e (mainnet)</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card todo">
|
<article class="card in_progress">
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h3><span class="phase-id">Phase 4</span> Prometheus Generator</h3>
|
<h3><span class="phase-id">Phase 4</span> Prometheus Generator</h3>
|
||||||
<span class="badge todo">TODO</span>
|
<span class="badge in_progress">IN PROGRESS</span>
|
||||||
</header>
|
</header>
|
||||||
<p class="count">0 / 6 complete</p>
|
<p class="count">4 / 6 complete</p>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
|
<li class="item done"><span class="mark">✓</span><span>Meta mixer (deterministic influence model)</span></li>
|
||||||
|
<li class="item done"><span class="mark">✓</span><span>Spawn name/ticker/lore generation (provider-abstracted)</span></li>
|
||||||
|
<li class="item done"><span class="mark">✓</span><span>Image prompt generation</span></li>
|
||||||
|
<li class="item done"><span class="mark">✓</span><span>Safety checks (denylist + moderation)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Generation input from receipt</span></li>
|
<li class="item"><span class="mark">○</span><span>Generation input from receipt</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Meta mixer</span></li>
|
<li class="item"><span class="mark">○</span><span>Wire real providers (keys) + admin approval UI</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Spawn name/ticker/lore generation</span></li>
|
|
||||||
<li class="item"><span class="mark">○</span><span>Image prompt generation</span></li>
|
|
||||||
<li class="item"><span class="mark">○</span><span>Safety checks</span></li>
|
|
||||||
<li class="item"><span class="mark">○</span><span>Admin approval UI</span></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card todo">
|
<article class="card in_progress">
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h3><span class="phase-id">Phase 5</span> Manual Pump.fun Launch Workflow</h3>
|
<h3><span class="phase-id">Phase 5</span> Manual Pump.fun Launch Workflow</h3>
|
||||||
<span class="badge todo">TODO</span>
|
<span class="badge in_progress">IN PROGRESS</span>
|
||||||
</header>
|
</header>
|
||||||
<p class="count">0 / 5 complete</p>
|
<p class="count">3 / 5 complete</p>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
<li class="item"><span class="mark">○</span><span>Approved Spawn package</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Spawn records DB + admin generate/launch endpoints</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Metadata JSON</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Mint/url/tx record input (admin)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Operator launch checklist</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Public Spawn record page (/spawn)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Mint/url/tx record input</span></li>
|
<li class="item"><span class="mark">○</span><span>Metadata JSON + IPFS upload</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Public Spawn record page</span></li>
|
<li class="item"><span class="mark">○</span><span>Operator launch checklist / semi-auto create</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card in_progress">
|
<article class="card in_progress">
|
||||||
|
|||||||
@@ -64,26 +64,26 @@
|
|||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"name": "Prometheus Generator",
|
"name": "Prometheus Generator",
|
||||||
"state": "todo",
|
"state": "in_progress",
|
||||||
"items": [
|
"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": "Generation input from receipt", "done": false },
|
||||||
{ "label": "Meta mixer", "done": false },
|
{ "label": "Wire real providers (keys) + admin approval UI", "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 }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"name": "Manual Pump.fun Launch Workflow",
|
"name": "Manual Pump.fun Launch Workflow",
|
||||||
"state": "todo",
|
"state": "in_progress",
|
||||||
"items": [
|
"items": [
|
||||||
{ "label": "Approved Spawn package", "done": false },
|
{ "label": "Spawn records DB + admin generate/launch endpoints", "done": true },
|
||||||
{ "label": "Metadata JSON", "done": false },
|
{ "label": "Mint/url/tx record input (admin)", "done": true },
|
||||||
{ "label": "Operator launch checklist", "done": false },
|
{ "label": "Public Spawn record page (/spawn)", "done": true },
|
||||||
{ "label": "Mint/url/tx record input", "done": false },
|
{ "label": "Metadata JSON + IPFS upload", "done": false },
|
||||||
{ "label": "Public Spawn record page", "done": false }
|
{ "label": "Operator launch checklist / semi-auto create", "done": false }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from "./fee";
|
|||||||
export * from "./sell";
|
export * from "./sell";
|
||||||
export * from "./receipt";
|
export * from "./receipt";
|
||||||
export * from "./prometheus";
|
export * from "./prometheus";
|
||||||
|
export * from "./spawn";
|
||||||
|
|||||||
51
packages/core/src/spawn.ts
Normal file
51
packages/core/src/spawn.ts
Normal 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;
|
||||||
|
}
|
||||||
48
packages/db/migrations/002_spawns.sql
Normal file
48
packages/db/migrations/002_spawns.sql
Normal 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);
|
||||||
@@ -341,6 +341,375 @@ export async function getEssenceSummary(): Promise<EssenceSummary> {
|
|||||||
return { roundId, totalLamports, contributionCount, recent };
|
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
|
* Close and clear the singleton pool. Intended for graceful shutdown / test
|
||||||
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.
|
* teardown; a subsequent {@link getPool} call lazily creates a fresh pool.
|
||||||
|
|||||||
@@ -9,12 +9,13 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "echo \"lint: ok (placeholder)\"",
|
"lint": "echo \"lint: ok (placeholder)\"",
|
||||||
"test": "echo \"test: ok (placeholder)\""
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pyre/core": "workspace:*"
|
"@pyre/core": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
* Responsibilities (§13): prompt templates, the meta mixer, output parser,
|
||||||
* safety checks, and the image-prompt generator. See §9 (Prometheus AI Meta
|
* 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:
|
* Design notes:
|
||||||
* - Prometheus generates Spawn *identity* only — it never controls funds.
|
* - Prometheus generates Spawn *identity* only — it never controls funds.
|
||||||
* - Meta influence is probabilistic, not deterministic.
|
* - Generation is PROVIDER-ABSTRACTED and STUBBED: it runs with NO network/keys
|
||||||
* - Do not allow users to force exact copyrighted/existing meme identities;
|
* today. Real providers slot in later behind `TextProvider` / `ImageProvider`
|
||||||
* produce inspired mutations, not direct clones.
|
* / `ModerationProvider` (see `providers.ts`). This package adds NO SDK deps.
|
||||||
*
|
* - The meta mixer is DETERMINISTIC from a seed derived from the input (no
|
||||||
* TODO: the AI client (Anthropic / OpenAI / image-gen provider) is configured
|
* `Math.random`): same input → same theme → same Spawn. The §9 "probabilistic"
|
||||||
* via `@pyre/config` and injected here — this package adds NO SDK dependencies
|
* weighting is realized as a fixed influence budget (40/25/20/15) sampled by a
|
||||||
* of its own. Wire the client through function parameters or a small interface.
|
* deterministic hash so results are reproducible and testable.
|
||||||
*
|
* - Produce inspired mutations, NOT direct clones of existing/copyrighted memes;
|
||||||
* Nothing here is implemented yet — every function throws "not implemented".
|
* 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.
|
* §9 influence budget. Weights sum to 100. Deterministic, not random — the
|
||||||
*
|
* "probabilistic" model is approximated by a fixed budget sampled via hashing.
|
||||||
* TODO: apply prompt templates and the probabilistic meta-influence weighting
|
|
||||||
* (burned archetypes / Essence themes / chaos mutation / operator seed).
|
|
||||||
*/
|
*/
|
||||||
export function buildPrompt(_input: PrometheusInput): string {
|
export const META_WEIGHTS = {
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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
|
* Deterministically pick up to `count` items from `pool`, seeded by `seed`.
|
||||||
* run safety checks, producing a Spawn package.
|
* Stable for a given (pool, seed, count).
|
||||||
*
|
|
||||||
* TODO: orchestrate buildPrompt -> AI client -> parseOutput -> runSafetyChecks.
|
|
||||||
* The AI client is injected (configured via @pyre/config).
|
|
||||||
*/
|
*/
|
||||||
export function runMetaMixer(_input: PrometheusInput): Promise<PrometheusOutput> {
|
function pick(pool: string[], count: number, seed: number): string[] {
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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`.
|
* Run the deterministic meta mixer. Builds a weighted `MetaTheme` from the
|
||||||
*
|
* input using the §9 influence budget (40% burned archetypes, 25% essence /
|
||||||
* TODO: validate/normalize the model JSON into the output schema.
|
* transmuted themes, 20% chaos mutation, 15% operator seed). Same input (and
|
||||||
|
* `operatorSeed`) → same theme.
|
||||||
*/
|
*/
|
||||||
export function parseOutput(_raw: string): PrometheusOutput {
|
export function runMetaMixer(input: PrometheusInput, operatorSeed?: string): MetaTheme {
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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.
|
* Parse + defensively validate the text JSON into typed fields. Tolerates
|
||||||
*
|
* surrounding prose by extracting the first JSON object. Missing fields become
|
||||||
* TODO: moderation + copyright/clone guards; return risk flags. Must reject
|
* empty strings; the ticker is normalized to uppercase alphanumerics.
|
||||||
* attempts to clone exact copyrighted or existing meme identities.
|
|
||||||
*/
|
*/
|
||||||
export function runSafetyChecks(_output: PrometheusOutput): Promise<string[]> {
|
export function parseOutput(raw: string): ParsedSpawn {
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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.
|
* Screen text against the local denylist and the injected `ModerationProvider`.
|
||||||
*
|
* Returns combined risk flags. Defaults to the offline stub moderation.
|
||||||
* TODO: derive an image prompt from the Spawn identity, honoring the same
|
|
||||||
* anti-clone safety rules.
|
|
||||||
*/
|
*/
|
||||||
export function generateImagePrompt(_output: PrometheusOutput): string {
|
export async function runSafetyChecks(
|
||||||
throw new Error(NOT_IMPLEMENTED);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
148
packages/prometheus/src/prometheus.test.ts
Normal file
148
packages/prometheus/src/prometheus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/prometheus/src/providers.ts
Normal file
170
packages/prometheus/src/providers.ts
Normal 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("|"));
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
'@pyre/db':
|
'@pyre/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/db
|
version: link:../../packages/db
|
||||||
|
'@pyre/prometheus':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/prometheus
|
||||||
'@pyre/solana':
|
'@pyre/solana':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/solana
|
version: link:../../packages/solana
|
||||||
@@ -181,6 +184,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
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:
|
packages/solana:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user