Skip to main content
workspace/projects/<id>/asset-manifest.json is the single pointer table for everything a project has generated. One key per asset slot, one record per pointer. The runtime shape is defined in cli/commands/generate.ts (Manifest type, readManifest / writeManifest). This page documents the schema, the slot-id convention, and how auto-versioning interacts with the pointer.

Full example

{
  "slots": {
    "scene-01-bg-image": {
      "kind": "image",
      "path": "assets/images/scene-01-bg-image.png",
      "model": "google/gemini-3-pro-image-preview",
      "costUsd": 0.15,
      "url": "https://orcdn.example/...",
      "generatedAt": "2026-05-19T08:14:22.118Z"
    },
    "scene-01-vid": {
      "kind": "video",
      "path": "assets/videos/scene-01-vid.mp4",
      "model": "kwaivgi/kling-v3.0-pro",
      "costUsd": 0.70,
      "generatedAt": "2026-05-19T08:18:01.501Z"
    },
    "scene-01-vo": {
      "kind": "voiceover",
      "path": "assets/voiceover/scene-01-vo.mp3",
      "model": "eleven_multilingual_v2",
      "costUsd": 0,
      "generatedAt": "2026-05-19T08:19:14.700Z"
    },
    "bg-music": {
      "kind": "music",
      "path": "assets/music/bg-music.mp3",
      "model": "music_v1",
      "costUsd": 0,
      "generatedAt": "2026-05-19T08:20:33.900Z"
    }
  }
}
Everything generated by ralphy generate <kind> lands here as a slot. The manifest is the “current pointer”; the file on disk is the latest version.

Schema

The runtime shape from cli/commands/generate.ts:
type Manifest = {
  slots: Record<string, {
    kind: "image" | "video" | "voiceover" | "music" | "captions";
    path: string;
    model?: string;
    costUsd?: number;
    url?: string;
    generatedAt: string;
  }>;
};

Fields

FieldTypeNullabilityNotes
slotsobjectrequiredMap from slot id to slot record. Empty object on a fresh project.
slots[id].kindenumrequiredimage, video, voiceover, music, captions.
slots[id].pathstringrequiredProject-relative path to the current version of the asset (e.g. assets/images/scene-01-bg-image.png).
slots[id].modelstringoptionalFull provider model id (e.g. google/gemini-3-pro-image-preview).
slots[id].costUsdnumberoptionalCost in USD for the latest generation, not lifetime. Lifetime cost comes from generations.jsonl.
slots[id].urlstringoptionalProvider URL when the asset was downloaded from a remote source. Useful for re-fetching.
slots[id].generatedAtISO 8601 stringrequiredTimestamp of the latest generation.
There is no top-level projectId, version, or totalAssets field in the runtime shape. (Some older example manifests under profiles/ have a richer shape — those are legacy and not used by the current ralphy generate flow.)

Slot ID convention

Slot ids are lowercase kebab-case. The canonical pattern is:
{scene-id}-{type}-{descriptor?}
Examples:
  • scene-01-bg-image — background image for scene 01
  • scene-01-vid — video for scene 01
  • scene-01-vo — voiceover for scene 01
  • scene-04-bg-image — background image for scene 04
  • bg-music — project-wide background music (no scene prefix)
The validator in cli/commands/generate.ts → normalizeSlot() accepts [a-zA-Z0-9_-]+ and normalizes uppercase to lowercase and _ to -, with a stderr warning. The canonical form is [a-z0-9-]+. Characters outside the relaxed set (spaces, dots, slashes, unicode) raise E_INPUT_INVALID.
// cli/commands/generate.ts
const SLOT_REGEX_RELAXED = /^[a-zA-Z0-9_-]+$/;
const SLOT_REGEX_CANONICAL = /^[a-z0-9-]+$/;
The auto-normalization exists because six of ten project postmortems flagged the previous hard-reject as their highest-frequency CLI friction.

Auto-versioning

When you regenerate a slot that already has a file, cli/lib/providers/media.ts → protectExistingAsset() archives the existing file before the new one lands:
assets/images/scene-01-bg-image.png        # before regen
assets/images/scene-01-bg-image.png        # after regen — new file
assets/images/scene-01-bg-image.v1.png     # archived original
A second regen archives v1 → v2:
assets/images/scene-01-bg-image.png        # latest
assets/images/scene-01-bg-image.v2.png     # second-latest
assets/images/scene-01-bg-image.v1.png     # original
The pattern is <base>.v{N}<ext> where N is the next free version number. The archiver scans siblings, finds the highest existing N, and uses N+1. Bypass with --force-overwrite only when the user explicitly asks for legacy destructive behavior.

Manifest interaction

The manifest’s path field always points at the latest version — the unversioned file. The archive files (.v1.png, .v2.png, …) are not listed in the manifest. To enumerate them, walk the assets directory. Every generation also appends a row to generations.jsonl (see Memory schemas). The full history of a slot lives in those rows; the manifest is the current pointer only.

Promote workflow

If the user prefers v1 over the latest, the promote flow is manual and user-driven — Ralphy does not flip the pointer without explicit consent.
# inspect the versions
ls workspace/projects/<id>/assets/images/scene-01-bg-image*

# promote v2 → latest
mv workspace/projects/<id>/assets/images/scene-01-bg-image.png \
   workspace/projects/<id>/assets/images/scene-01-bg-image.v3.png
mv workspace/projects/<id>/assets/images/scene-01-bg-image.v2.png \
   workspace/projects/<id>/assets/images/scene-01-bg-image.png
The manifest’s path does not change (the unversioned filename is the same). The new file is the new latest. A future ralphy project doctor verb may automate this, but until then the swap is by hand.

Manifest vs generations.jsonl

FilePurpose
asset-manifest.jsonCurrent pointer per slot. “What is the latest version of scene-01-bg-image?”
logs/generations.jsonlHistory. “How many times has scene-01-bg-image been generated? At what cost? With what prompt?”
The manifest is the read-cache for the editor and renderer — it knows where every slot’s file is right now. The log is the audit trail. The two never disagree about the current file on disk: every successful ralphy generate <kind> writes the file, appends to the log, and updates the manifest in the same call.

Writer behaviour

// cli/commands/generate.ts
async function readManifest(projectId: string): Promise<Manifest>;
async function writeManifest(projectId: string, m: Manifest): Promise<void>;
readManifest returns {slots: {}} when the file is absent or unparseable — empty-slot recovery is silent so a corrupted manifest does not block a fresh generation. writeManifest does a full rewrite of the file with JSON.stringify(m, null, 2). The file is small (a few hundred bytes to a few KB per project), so the rewrite cost is negligible. This is the one place in the project memory where rewrites happen. The JSONL logs are append-only; the manifest is rewrite-allowed because it tracks current pointers, not history.

Sister files: render output

render/final.mp4 and friends are written by ralphy render <id> and do not appear in the manifest. The renderer reads the manifest to find slot files, composes the video, and writes the output to render/ directly. Render outputs are tracked separately by ralphy project show <id>.

Doctor verb

ralphy project doctor <id>
The doctor verb walks the manifest slot-by-slot and ffprobes every file, flagging divergences (missing file, wrong duration, wrong dimensions, broken codec). It exits non-zero on any red. Postmortems (tokyo, kbo) flagged that the manifest claims drift from the actual on-disk file on long sessions; the doctor is the recovery path.