Skip to main content
Every project keeps three append-only JSONL logs under workspace/projects/<id>/logs/ and one pointer table at workspace/projects/<id>/asset-manifest.json. The logs are the audit trail — every model call, every user prompt, every uploaded ref. The manifest is the slot pointer table — which file is the current version of scene-01-bg, which is the previous. Together they enforce AGENTS.md invariant #13: append-only on generations, never delete or overwrite user / agent-produced artifacts without explicit user request. The schemas and the writer live in cli/lib/gen-log.ts.

The three logs

workspace/projects/<id>/logs/
├── generations.jsonl     # every model call (fal / ElevenLabs / OpenRouter / ffmpeg)
├── user-prompts.jsonl    # every user-facing prompt or request
└── user-assets.jsonl     # every user-uploaded reference (screenshot, photo, doc, URL)
All three are append-only by definition. Never truncate, rewrite, or filter in place — read-and-rewrite-to-tidy is a defect. Failed and rejected generations stay on disk until the user explicitly purges them.

generations.jsonl schema

One JSON object per line. Fields from cli/lib/gen-log.ts:
type GenerationEntry = {
  timestamp: string;            // ISO 8601, set at append time
  provider: Provider;           // "fal" | "elevenlabs" | "openai" | "openrouter"
                                // | "vercel" | "ffmpeg" | "replicate" | "other"
  endpoint: string;             // e.g. "fal-ai/kling-video/v3/pro/image-to-video"
  kind: "image" | "video" | "audio" | "music" | "voiceover"
      | "sfx" | "text" | "embed" | "other";
  slot?: string;                // e.g. "scene-01-bg-image"
  input: Record<string, unknown>;
  output?: {
    url?: string;               // hosted URL (preferred over byte blobs)
    local?: string;             // local path inside the project
    bytes?: number;
  };
  status: "ok" | "error";
  error?: string;
  latency_ms?: number;
  cost_usd?: number;            // best-effort estimate
  request_id?: string;          // fal queue id, elevenlabs id, etc.
  note?: string;                // free-form, e.g. "clip-03 v2 hand crumples sample"
};
A typical entry:
{
  "timestamp": "2026-05-20T10:42:18.901Z",
  "provider": "fal",
  "endpoint": "fal-ai/kling-video/v3/pro/image-to-video",
  "kind": "video",
  "slot": "scene-03-clip",
  "input": {
    "prompt": "Megan turns to camera, slight smile, raises mug.",
    "image_url": "workspace/projects/coffee-shop-001/assets/scene-03-bg.png",
    "duration": 5,
    "audio": true
  },
  "output": {
    "url": "https://v3.fal.media/files/.../output.mp4",
    "local": "workspace/projects/coffee-shop-001/assets/scene-03-clip.mp4",
    "bytes": 4218492
  },
  "status": "ok",
  "latency_ms": 78421,
  "cost_usd": 0.96,
  "request_id": "fal-queue-abc123",
  "note": "v2 after v1 had identity drift on Megan"
}
The slot field exists because per-slot cost rollups need it. Two postmortems (noski, venom) had to grep .locked-vN filenames to reconstruct slot-level spend because slot wasn’t there yet; it is now part of the schema. The note field is for the agent’s human-readable tag — what version this is, what changed since the previous version, why the user asked for the regen. Always populate it.

user-prompts.jsonl schema

type UserPromptEntry = {
  timestamp: string;
  text: string;
  stage?: string;     // "brief" | "scenario-feedback"
                      // | "regeneration-request" | "no-ref-consent" | ...
  note?: string;
};
A typical entry:
{
  "timestamp": "2026-05-20T10:30:01.123Z",
  "text": "make the second scene more cinematic, less product-shoot",
  "stage": "scenario-feedback",
  "note": "user response to v1 scenario draft"
}
The stage field is how the postmortem reconstructs the chronology — intake → scenario draft → feedback → regen → final sign-off. The agent writes one entry per substantive user prompt; trivial back-channel (“yes go ahead”) does not need to land here unless it changes direction. The no-ref-consent stage is reserved — see /concepts/references. Every --no-ref-consent "<reason>" override appends one entry with the reason text.

user-assets.jsonl schema

type UserAssetEntry = {
  timestamp: string;
  kind: "screenshot" | "photo" | "video" | "audio" | "doc" | "ref-url" | "other";
  source: string;     // original path or URL from user
  dest?: string;      // stored path inside the project, if copied in
  purpose?: string;   // "character-ref" | "product-ref"
                      // | "brand-screenshot" | ...
  note?: string;
};
A typical entry:
{
  "timestamp": "2026-05-20T10:15:42.500Z",
  "kind": "ref-url",
  "source": "https://www.tiktok.com/@user/video/7321...",
  "dest": "workspace/.ralph/refs/tiktok-7321/source.mp4",
  "purpose": "style-ref",
  "note": "the user said 'I want it like this' and dropped this URL"
}
Asset entries log what the user gave Ralphy — not what Ralphy produced. They are how the agent answers “what references did the user attach to this project.”

The append-only contract

The rules from AGENTS.md invariant #13, applied to the three logs:
  • Never truncate. No “drop entries older than X” rewrites. The file grows monotonically.
  • Never filter in place. If you want a filtered view, write a new file under logs/views/<name>.jsonl or pipe through jq. Do not edit the source file.
  • Never reorder. Entries are in append order. Even if a timestamp is backfilled for an old call, the file order reflects when the entry was written.
  • Append-only is bidirectional. A failed call also lands an entry (with status: "error" and error: "..."). The next agent looking at the log sees the failed attempts and knows what was tried.
The writer appendJsonl(file, entry) does exactly this — fs.mkdir -p then fs.appendFile. No rewrite path exists in the codebase.

asset-manifest.json schema

The manifest is not a log. It is a pointer table: which file on disk is the current version of which slot, plus per-slot metadata.
{
  "version": 1,
  "project_id": "coffee-shop-001",
  "slots": {
    "scene-01-bg": {
      "kind": "image",
      "current": "assets/scene-01-bg.png",
      "versions": [
        { "version": 1, "file": "assets/scene-01-bg.v1.png", "created": "2026-05-20T10:32:00Z" },
        { "version": 2, "file": "assets/scene-01-bg.png",    "created": "2026-05-20T10:45:30Z" }
      ],
      "promoted": null
    },
    "scene-01-clip": {
      "kind": "video",
      "current": "assets/scene-01-clip.mp4",
      "versions": [
        { "version": 1, "file": "assets/scene-01-clip.mp4", "created": "2026-05-20T10:48:12Z" }
      ],
      "promoted": null
    },
    "voiceover-en": {
      "kind": "voiceover",
      "current": "assets/voiceover-en.mp3",
      "versions": [
        { "version": 1, "file": "assets/voiceover-en.mp3", "created": "2026-05-20T10:52:00Z" }
      ],
      "promoted": null
    }
  }
}
The manifest is updated by ralphy generate after every produced file. Three operations:
  • Add a new slot. First generation for a slot — manifest grows by one entry.
  • Add a new version to a slot. Regen — existing file at current is archived to .v{N}.<ext>, manifest’s versions array grows, current points at the new file.
  • Promote a version. Explicit user say-so — ralphy project promote <slot> v1 swaps current to point at .v1.<ext> and archives the previous-current.

Versioning: .v2, .v3, …

Since commit 753d2f7 (2026-05-19), ralphy generate {image,video,music,voiceover,sfx} auto-archives the existing slot file before writing the new one. The rule:
  1. Slot has no file → write <slot>.<ext>.
  2. Slot has <slot>.<ext> → archive to <slot>.v1.<ext>, write the new file as <slot>.<ext>.
  3. Slot has <slot>.<ext> plus <slot>.v1.<ext> → archive current to <slot>.v2.<ext>, write the new file as <slot>.<ext>.
  4. And so on.
The unversioned filename is always the current version. The .v1, .v2, … are the previous versions. The manifest’s current field also points at the unversioned filename. --force-overwrite opts into legacy destructive behavior — write directly, skip the archive. Pass it only when the user explicitly asks (“just overwrite, I do not need the old one”). The default is safe.

The promote workflow

The user previewed v2, did not like it, wants v1 back as the current version:
ralphy project promote coffee-shop-001 scene-01-bg v1
# Archives current (v2's file) to scene-01-bg.v3.png, restores v1 to scene-01-bg.png.
# Manifest's current now points at the restored file.
# Manifest's versions array gains a new {version: 3, ...} entry for the demoted v2.
Promotion is additive — it does not delete the demoted version. The demoted file is archived as the next version number. The append-only contract holds: every file that ever existed is still on disk.

Why this is structured this way

Four downstream uses rely on the log + manifest contract. Postmortem mining. The /postmortem skill reads generations.jsonl, user-prompts.jsonl, and the manifest to write 02-lessons.md, 03-bugs.md, 04-models-cost.md. The lessons file is what the next project’s agent reads at session start. The mining is only possible because nothing was truncated. Cross-session memory. When you reopen a project after a week, the agent reads the logs to reconstruct what happened. A note field on every generation that says “v2 after v1 had identity drift on Megan” tells the next agent the lineage immediately. Cost rollup. ralphy project cost <id> sums cost_usd across generations.jsonl. The per-slot breakdown depends on the slot field being present. The per-stage breakdown (image vs. video vs. music) depends on kind. Audit and debug. When the user asks “why did this scene look weird,” the agent reads the generation entries for that slot, sees the prompts and the model picks, and can diff v1 against v2.

The convenience writer

loggedFetch() in cli/lib/gen-log.ts wraps fetch() with auto-timing and auto-logging. The CLI uses it for every provider call:
const resp = await loggedFetch({
  projectId: "coffee-shop-001",
  provider: "fal",
  endpoint: "fal-ai/kling-video/v3/pro/image-to-video",
  kind: "video",
  input: body,
  note: "scene-03 v2",
}, url, { method, headers, body: JSON.stringify(body) });
The wrapper appends a generation entry with status, latency_ms, request_id, and (when the response is JSON) the output URL — automatically, even on error. The verb-level code does not need to write logs by hand for the common case.

Useful reading verbs

ralphy project log <id>                       # raw JSONL, pretty-printed
ralphy project log <id> --kind video          # filter by kind
ralphy project log <id> --status error        # only failures
ralphy project timeline <id>                  # chronological view across all 3 logs
ralphy project cost <id>                      # cost rollup
ralphy project cost <id> --by-slot            # per-slot breakdown