Skill: cpojer/orf (version 1.0.2)

--- name: orf-digest description: "On-demand ORF news digest in German. Use when the user says 'orf', 'pull orf', or 'orf 10'. Focus on Austrian politics (Inland) and international politics (Ausland) + major headlines; exclude sports. Send each item as its own message (Title + Age + Link). Then generate a Nano Banana image in a cartoon ZiB studio with the anchor presenting the news, plus subtle Easter eggs based on the selected stories." --- # ORF Digest (news.orf.at) ## Command format Interpret a user message that starts with `orf` as a request for an ORF News digest. Supported forms: - `orf` → default 5 items - `orf <n>` → n items (max 15) - `orf inland` / `orf ausland` → bias selection - `orf <n> inland|ausland` → both ## Source + scope - Primary source: `news.orf.at` (German) - Prefer: **Inland** politics, **Ausland** / international politics, and major headlines. - Exclude: sports (Sport). ## Output requirements - Do **not** send any extra commentary/preamble/epilogue. - Send results as **individual messages**. - Each item message must be exactly: - first line: the headline (German) - second line: `<age>` (e.g. `45m ago`, `6h ago`, `2d ago`) - third line: the ORF link - After the item messages, send **one final message** with the generated image. - The image must visually incorporate the pulled news on the wraparound studio video wall using **4–6 distinct story panels**. - **Panel layout (must):** - TOP: big bold text (1–2 words, ALL CAPS). You must invent this. - MIDDLE: smaller text (3–6 words) that describes the story. You must invent this. - The two lines must **not** form a connected sentence. - Avoid repeating the same words between the two lines. - BOTTOM: exactly 1–2 simple icons (no maps, no busy collages) - **Icon variety:** make icons distinct across panels whenever possible. - Do not reuse the same icon pair across multiple panels. - Avoid overusing generic icons (e.g. globe + pin); only use them when no better match exists. - **Readability:** keep text minimal and large enough to render cleanly. - No logos/watermarks. - If the chat provider requires non-empty text for media, use a minimal caption `.`. ## Procedure 1. Parse `n` and optional `focus` (`inland`|`ausland`) from the user message. 2. Run `python3 skills/orf-digest/scripts/orf.py --count <n> --focus <focus> --format json`. 3. Send each returned item as its own message (3-line format). 4. Generate the ZiB studio mood image via Nano Banana: - Build prompt from items: `python3 skills/orf-digest/scripts/orf.py --count <n> --focus <focus> --format json | node skills/orf-digest/scripts/zib_prompt.mjs` - Generate: `skills/orf-digest/scripts/generate_zib_nano_banana.sh ./tmp/orf-zib/zib.png` - Send image as final message. If fetching/parsing fails or returns 0 items: - Use the browser tool to open `https://news.orf.at/`, pick N non-sport headlines by judgment, and send them in the same 3-line format. - Still generate a ZiB studio image with a few generic political-news Easter eggs.

path: scripts/nano_banana_mood.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Optional


def load_gemini_key() -> Optional[str]:
    """Load Gemini API key.

    Order:
    1) GEMINI_API_KEY env var
    2) ~/.openclaw/openclaw.json (OpenClaw config)
    """

    env_key = (os.environ.get("GEMINI_API_KEY") or "").strip()
    if env_key:
        return env_key

    cfg_path = Path.home() / ".openclaw" / "openclaw.json"
    try:
        raw = cfg_path.read_text(encoding="utf-8")
        cfg = json.loads(raw)
        api_key = (
            cfg.get("skills", {})
            .get("entries", {})
            .get("nano-banana-pro", {})
            .get("apiKey")
            or ""
        ).strip()
        return api_key or None
    except Exception:
        return None


def main() -> int:
    ap = argparse.ArgumentParser(description="Generate a mood image via Nano Banana Pro.")
    ap.add_argument("--prompt", required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--resolution", choices=["1K", "2K", "4K"], default="1K")
    args = ap.parse_args()

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]
            "Missing GEMINI_API_KEY (env) or nano-banana-pro apiKey in ~/.openclaw/openclaw.json",
            file=sys.stderr,
        )
        return 2

    out_path = Path(args.out).expanduser().resolve()
    out_path.parent.mkdir(parents=True, exist_ok=True)

    from google import genai
    from google.genai import types
    from PIL import Image as PILImage
    from io import BytesIO

    client = genai.Client([REDACTED_SECRET])

    response = client.models.generate_content(
        model="gemini-3-pro-image-preview",
        contents=args.prompt,
        config=types.GenerateContentConfig(
            response_modalities=["TEXT", "IMAGE"],
            image_config=types.ImageConfig(image_size=args.resolution),
        ),
    )

    image_saved = False
    for part in getattr(response, "parts", []) or []:
        inline = getattr(part, "inline_data", None)
        if inline is None:
            continue
        data = getattr(inline, "data", None)
        if not data:
            continue

        if isinstance(data, str):
            import base64

            data = base64.b64decode(data)

        image = PILImage.open(BytesIO(data))
        if image.mode == "RGBA":
            rgb = PILImage.new("RGB", image.size, (255, 255, 255))
            rgb.paste(image, mask=image.split()[3])
            rgb.save(str(out_path), "PNG")
        else:
            image.convert("RGB").save(str(out_path), "PNG")
        image_saved = True
        break

    if not image_saved:
        print("No image returned by model", file=sys.stderr)
        return 1

    print(out_path.as_posix())
    print(f"MEDIA: {out_path.as_posix()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/orf.py
content: #!/usr/bin/env python3
import argparse
import datetime as dt
import json
import sys
import urllib.request
import xml.etree.ElementTree as ET


FEEDS = {
    # ORF provides RSS feeds; see https://rss.orf.at/
    # We'll merge them and dedupe by link.
    "news": "https://rss.orf.at/news.xml",
    "oesterreich": "https://rss.orf.at/oesterreich.xml",
    "sport": "https://rss.orf.at/sport.xml",
}

SPORT_HINTS = [
    "sport",
    "bundesliga",
    "champions league",
    "europa league",
    "ski",
    "skifahren",
    "tennis",
    "fußball",
    "fussball",
    "formel 1",
    "nba",
    "nfl",
]


def fetch(url: str) -> bytes:
    req = urllib.request.Request(
        url,
        headers={
            "accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8",
            "user-agent": "clawdbot-orf-digest/1.0",
        },
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return resp.read()


def parse_rss(xml_bytes: bytes) -> list[dict]:
    root = ET.fromstring(xml_bytes)
    items: list[dict] = []

    # ORF uses RSS 1.0 (RDF) with namespaces.
    # We avoid hardcoding the full namespace URIs by scanning for elements named "item".
    for el in root.iter():
        if not str(el.tag).endswith("item"):
            continue

        def find_text(name_suffix: str) -> str:
            for child in list(el):
                if str(child.tag).endswith(name_suffix) and child.text:
                    return child.text.strip()
            return ""

        title = find_text("title")
        link = find_text("link")
        pub_date = find_text("pubDate") or find_text("date")

        if title and link:
            items.append({"title": title, "link": link, "pubDate": pub_date})

    return items


def parse_date(dt_str: str):
    if not dt_str:
        return None

    # RFC2822 (RSS 2.0)
    try:
        return dt.datetime.strptime(dt_str, "%a, %d %b %Y %H:%M:%S %z")
    except Exception:
        pass

    # ISO8601 (ORF RSS 1.0 uses dc:date)
    try:
        # e.g. 2026-01-14T12:03:32+01:00
        return dt.datetime.fromisoformat(dt_str)
    except Exception:
        return None


def age_text(published, now: dt.datetime) -> str:
    if published is None:
        return ""

    delta = now - published.astimezone(now.tzinfo)
    seconds = max(0, int(delta.total_seconds()))

    if seconds < 60:
        return "just now"

    minutes = seconds // 60
    if minutes < 60:
        return f"{minutes}m ago"

    hours = minutes // 60
    if hours < 48:
        return f"{hours}h ago"

    days = hours // 24
    return f"{days}d ago"


def is_sportish(title: str, link: str) -> bool:
    t = (title or "").lower()
    l = (link or "").lower()
    if "/sport" in l:
        return True
    return any(h in t for h in SPORT_HINTS)


def score_item(title: str, focus: str) -> int:
    import re

    t = (title or "").lower()
    score = 0

    def has(patterns: list[str]) -> bool:
        return any(re.search(p, t) for p in patterns)

    # Default: politics + major news.
    politics = [
        r"\bregierung\b",
        r"\bparlament\b",
        r"\bkanzler\b",
        r"\bminister(in)?\b",
        r"\bwahl(en)?\b",
        r"\bkoalition\b",
        r"\bbudget\b",
        r"\bspö\b",
        r"\bövp\b",
        r"\bfpo\b|\bfpö\b",
        r"\bneos\b",
        r"\bgrüne\b|\bgruene\b",
    ]

    international = [
        r"\bukraine\b",
        r"\bruss\w*",
        r"\bisrael\b",
        r"\bgaza\b",
        r"\bchina\b",
        r"\busa\b|\bvereinigte(n)? staaten\b",
        r"\beu\b|\beuropa\b|\bbrüssel\b|\bbruessel\b",
        r"\bnato\b",
        r"\bkrieg\b",
    ]

    if has(politics) or has(international):
        score += 5

    # Slight boost for focus.
    if focus == "inland" and has([r"\bösterreich\b|\boesterreich\b", r"\bwien\b", r"\bparlament\b", r"\bregierung\b"]):
        score += 3
    if focus == "ausland" and has(international):
        score += 3

    return score


def main() -> int:
    ap = argparse.ArgumentParser(description="Fetch ORF RSS items (German), exclude sports, rank for politics.")
    ap.add_argument("--count", type=int, default=5)
    ap.add_argument("--focus", choices=["auto", "inland", "ausland"], default="auto")
    ap.add_argument("--format", choices=["json", "text"], default="json")
    args = ap.parse_args()

    count = max(1, min(int(args.count), 15))
    focus = args.focus

    now = dt.datetime.now(dt.timezone.utc)

    # Merge feeds; always exclude the sport feed.
    # Always include news; include regional Austria feed only when the user explicitly focuses on inland.
    feed_urls = [FEEDS["news"]] + ([FEEDS["oesterreich"]] if focus == "inland" else [])

    merged: list[dict] = []
    for url in feed_urls:
        merged.extend(parse_rss(fetch(url)))

    seen: set[str] = set()
    items: list[dict] = []

    for it in merged:
        link = it.get("link") or ""
        if not link or link in seen:
            continue
        seen.add(link)

        title = it.get("title") or ""
        if is_sportish(title, link):
            continue

        published = parse_date(it.get("pubDate") or "")
        items.append(
            {
                "title": title,
                "link": link,
                "published": published.isoformat() if published else None,
                "age": age_text(published, now),
                "score": score_item(title, "inland" if focus == "inland" else "ausland" if focus == "ausland" else "auto"),
            }
        )

    # Prefer high score; tie-break with recency.
    def sort_key(it: dict):
        published = it.get("published") or ""
        return (it.get("score", 0), published)

    ranked = sorted(items, key=sort_key, reverse=True)[:count]

    if args.format == "text":
        for it in ranked:
            sys.stdout.write(f"{it['title']}\n{it.get('age','')}\n{it['link']}\n\n")
        return 0

    sys.stdout.write(json.dumps({"items": ranked}, ensure_ascii=False, indent=2))
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/generate_zib_nano_banana.sh
content: #!/bin/sh
set -eu

OUT_PATH="$1"
COUNT="${2:-5}"
FOCUS="${3:-auto}"

VENV_DIR="./tmp/orf-venv"
PY="$VENV_DIR/bin/python"

if [ ! -x "$PY" ]; then
  python3 -m venv "$VENV_DIR"
  "$PY" -m pip install --quiet --disable-pip-version-check google-genai pillow
fi

JSON="$(python3 skills/orf-digest/scripts/orf.py --count "$COUNT" --focus "$FOCUS" --format json)"
PROMPT="$(printf "%s" "$JSON" | node skills/orf-digest/scripts/zib_prompt.mjs)"

"$PY" skills/orf-digest/scripts/nano_banana_mood.py --out "$OUT_PATH" --resolution 1K --prompt "$PROMPT"


path: scripts/zib_prompt.mjs
content: #!/usr/bin/env node

function readStdin() {
  return new Promise((resolve, reject) => {
    let data = "";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", (chunk) => (data += chunk));
    process.stdin.on("end", () => resolve(data));
    process.stdin.on("error", reject);
  });
}

function pickEasterEggs(items) {
  const eggs = [];
  eggs.push("a tiny banana sticker hidden on a studio camera");
  eggs.push("a small lion plush sitting on a side table");

  const titles = items.map((i) => String(i.title || "")).join(" \n ").toLowerCase();

  if (/(regierung|parlament|wahl|koalition|kanzler|minister)/.test(titles)) {
    eggs.push("a subtle miniature parliament building silhouette as a desk ornament" );
  }

  if (/(eu|brüssel|nato|ukraine|russ|israel|gaza|usa|china)/.test(titles)) {
    eggs.push("a small globe with a few tiny glowing pins (no labels)" );
  }

  eggs.push("a coffee mug with a simple zebra-like pattern (no letters)" );

  return eggs.slice(0, 4);
}

function headlineSnippet(rawTitle) {
  return String(rawTitle || "")
    .replace(/\s+/g, " ")
    .trim()
    .replace(/[\u201c\u201d\u201e\u201f]/g, '"');
}

function splitTitle(rawTitle) {
  const title = headlineSnippet(rawTitle);
  const parts = title.split(":");
  if (parts.length < 2) return { lead: title, main: title };
  const lead = parts[0].trim();
  const main = parts.slice(1).join(":").trim();
  return { lead, main: main || lead };
}

function panelHeadline(rawTitle) {
  // 1–2 word ALL-CAPS headline that we INVENT (not a sentence fragment).
  // Prefer nouns/entities over verb phrases.
  const { lead, main } = splitTitle(rawTitle);
  const full = `${lead} ${main}`.toLowerCase();

  const pick = (...words) =>
    words
      .filter(Boolean)
      .join(" ")
      .replace(/\s+/g, " ")
      .trim()
      .toUpperCase() || "NEWS";

  // Event/type mappings (prefer concrete event words over generic entities).
  if (/(marine|enterte|enterung|enter)/.test(full) && /(tanker)/.test(full)) return pick("MARINE");
  if (/(tanker)/.test(full)) return pick("TANKER");
  if (/(marine|enterte|enterung|enter)/.test(full)) return pick("MARINE");
  if (/(sanktion|handel|zoll|zölle|tarif)/.test(full)) return pick("HANDEL");
  if (/(abkommen|deal|freihandel|mercosur)/.test(full)) return pick("ABKOMMEN");
  if (/(treffen|gespräch|verhandlung)/.test(full)) return pick("DIPLOMATIE");
  if (/(anschlag|attentat|explosion)/.test(full)) return pick("ANSCHLAG");

  // Strong topic/entity mappings.
  if (/(ukraine|ukrain|selenskyj)/.test(full)) return pick("UKRAINE");
  if (/(gaza)/.test(full)) return pick("GAZA");
  if (/(israel)/.test(full)) return pick("ISRAEL");
  if (/(eu|brüssel|bruessel|europ)/.test(full)) return pick("EU");
  if (/(nato)/.test(full)) return pick("NATO");
  if (/(frankreich)/.test(full)) return pick("FRANKREICH");
  if (/(russ)/.test(full)) return pick("RUSSLAND");
  if (/(china)/.test(full)) return pick("CHINA");
  if (/(iran)/.test(full)) return pick("IRAN");
  if (/(grönland|groenland)/.test(full)) return pick("GRÖNLAND");

  // Fallback: pick up to 2 noun-ish tokens from the main clause.
  const stop = new Set([
    "zu",
    "und",
    "der",
    "die",
    "das",
    "den",
    "dem",
    "des",
    "ein",
    "eine",
    "einer",
    "einem",
    "eines",
    "im",
    "in",
    "am",
    "an",
    "auf",
    "bei",
    "nach",
    "von",
    "mit",
    "für",
    "über",
    "gegen",
    // common verbs / auxiliaries we don't want in the headline
    "ist",
    "sind",
    "war",
    "waren",
    "wird",
    "werden",
    "bleibt",
    "bleiben",
    "stoppt",
    "stoppte",
    "stellt",
    "stellen",
    "erleichtert",
    "fordert",
    "kündigt",
    "kuendigt",
  ]);

  const tokens = String(main || "")
    .replace(/[“”„‟]/g, "")
    .replace(/[()]/g, " ")
    .replace(/[^\p{L}\p{N}\s-]/gu, "")
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .filter((w) => !stop.has(w.toLowerCase()));

  let first = tokens[0] || "NEWS";
  let second = tokens[1];

  // Collapse things like "G-7-Treffen" → "G-7".
  const gMatch = first.match(/^(G-?\d+)/i);
  if (gMatch) {
    first = gMatch[1];
    second = undefined;
  }

  // Keep very short acronyms (e.g. EU) + next token.
  if (/^(EU|UNO|G-?\d+)$/i.test(first) && second) {
    return pick(first, second);
  }

  return pick(first);
}

function subtitleSnippet(rawTitle) {
  // 3–6 word “news-style” subtitle in German.
  // Prefer rephrasing via light templates; avoid copying the original headline verbatim.
  const title = headlineSnippet(rawTitle);
  const t = title.toLowerCase();

  const topic = panelHeadline(rawTitle);
  const topicWords = new Set(
    topic
      .toLowerCase()
      .replace(/[^\p{L}\p{N}\s-]/gu, "")
      .split(/\s+/)
      .filter(Boolean)
  );

  const pick = (s) =>
    String(s)
      .replace(/\s+/g, " ")
      .trim()
      .split(/\s+/)
      .slice(0, 6)
      .join(" ");

  // Template-ish rephrasings for common topics.
  if (/(tanker)/.test(t) && /(mittelmeer)/.test(t)) {
    return pick("Kontrolle im Mittelmeer, Tanker gestoppt");
  }
  if (/(marine|enterte|enterung)/.test(t) && /(tanker)/.test(t)) {
    return pick("Boarding-Einsatz, Tanker unter Kontrolle");
  }
  if (/(eu)/.test(t) && /(wachsam|vorsicht|alarmiert)/.test(t)) {
    return pick("Erleichterung, trotzdem erhöhte Aufmerksamkeit");
  }
  if (/(trump)/.test(t) && /(gaza)/.test(t)) {
    return pick("Immobilienpläne sorgen für Debatte");
  }
  if (/(ukraine)/.test(t) && /(usa)/.test(t) && /(russ)/.test(t)) {
    return pick("Gespräche zwischen drei Seiten");
  }

  // Generic fallback: extract a few content words, but remove topic words.
  const rawWords = title
    .replace(/[“”„‟]/g, "")
    .replace(/[:–—-]/g, " ")
    .replace(/[^\p{L}\p{N}\s-]/gu, "")
    .split(/\s+/)
    .filter(Boolean);

  const stop = new Set([
    "zu",
    "und",
    "der",
    "die",
    "das",
    "den",
    "dem",
    "des",
    "ein",
    "eine",
    "einer",
    "einem",
    "eines",
    "im",
    "in",
    "am",
    "an",
    "auf",
    "bei",
    "nach",
    "von",
    "mit",
    "für",
    "über",
    "gegen",
    "sollen",
    "soll",
    "wird",
    "werden",
    "ist",
    "sind",
    "war",
    "waren",
  ]);

  const words = rawWords
    .map((w) => w.trim())
    .filter(Boolean)
    .filter((w) => !stop.has(w.toLowerCase()))
    .filter((w) => !topicWords.has(w.toLowerCase()))
    .slice(0, 6);

  const finalWords = (words.length >= 3 ? words : rawWords.slice(0, 6)).slice(0, 6);
  return finalWords.join(" ");
}

function iconPairForTitle(t) {
  // Return an array of 1–2 icon names (strings). Keep them simple and distinct.
  // Avoid the generic globe/pin unless absolutely necessary.

  // Trade / sanctions.
  if (/(zoll|zölle|tarif|drohung|drohungen|handel|sanktion)/.test(t)) return ["shipping container icon", "coin stack icon"];

  // Diplomacy / talks.
  if (/(treffen|gespräch|gespraech|verhandlung)/.test(t)) return ["handshake icon", "speech bubble icon"];

  // EU-ish policy / institutions.
  if (/(eu|brüssel|bruessel)/.test(t)) return ["circle of stars icon", "shield icon"];

  // NATO / defense.
  if (/(nato)/.test(t)) return ["radar sweep icon", "cracked shield icon"];

  // Maritime: tanker / boarding.
  if (/(marine|enterte|enterung|enter)/.test(t) && /(tanker)/.test(t)) return ["boarding ladder icon", "ship icon"];
  if (/(tanker)/.test(t)) return ["ship icon", "stop sign icon"];

  // Ukraine / justice / arrests.
  if (/(ukraine|ukrain|selenskyj|gefangene|gefangener|mörder|morde|mord)/.test(t)) return ["handcuffs icon", "justice scale icon"];

  // Attack / incident.
  if (/(kabul|anschlag|attentat|explosion|restaurant)/.test(t)) return ["warning triangle icon", "siren icon"];

  // Elections / domestic politics.
  if (/(wahl|parlament|regierung|koalition|minister|budget)/.test(t)) return ["ballot box icon", "parliament building icon"];

  // Tech bans / telecom.
  if (/(huawei|zte|netz|netzen|5g|telekom|mobilfunk)/.test(t)) return ["antenna tower icon", "forbidden sign icon"];

  // Nuclear.
  if (/(atomkraft|atomkraftwerk|akws|nuklear|reaktor)/.test(t)) return ["radiation symbol icon", "crosshair target icon"];

  // Middle East / Gaza.
  if (/(gaza)/.test(t)) return ["building icon", "speech bubble icon"];

  // US politics / Trump.
  if (/(trump)/.test(t)) return ["suit tie icon", "building icon"];

  // Last resort: pick from a rotating pool to avoid duplicates.
  return null;
}

function storyPanel(item, ctx) {
  const title = String(item.title || "");
  const t = title.toLowerCase();
  const headline = panelHeadline(title);
  const subtitle = subtitleSnippet(title);

  const layout = `layout within the panel: TOP: big bold all-caps text \"${headline}\" (1–2 words, not a sentence). MIDDLE: smaller text \"${subtitle}\" (3–6 words). The two lines must not form a connected sentence; avoid repeating words between them. BOTTOM: exactly 1–2 simple icons (flat, high-contrast), no extra symbols, no charts, no maps.`;

  // Icon variety rule: try hard to avoid reusing the same pair.
  let icons = iconPairForTitle(t);

  const pool = [
    ["spotlight icon", "exclamation mark icon"],
    ["newspaper icon", "clock icon"],
    ["microphone icon", "camera icon"],
    ["shield icon", "checkmark icon"],
    ["barrier icon", "warning triangle icon"],
    ["gavel icon", "document icon"],
  ];

  if (!icons) {
    // Pick the first unused pair from the pool.
    icons = pool.find((pair) => !ctx.usedPairs.has(pair.join("|"))) || pool[0];
  }

  // If this pair was already used, try to find an alternative from the pool.
  const key = icons.join("|");
  if (ctx.usedPairs.has(key)) {
    const alt = pool.find((pair) => !ctx.usedPairs.has(pair.join("|")));
    if (alt) icons = alt;
  }

  ctx.usedPairs.add(icons.join("|"));

  if (icons.length === 1) {
    return `a dedicated panel (${layout}) with exactly one icon: ${icons[0]}`;
  }

  return `a dedicated panel (${layout}) with exactly two icons: ${icons[0]} + ${icons[1]}`;
}

function storyProps(items) {
  const ctx = { usedPairs: new Set() };
  return items.slice(0, 6).map((it) => storyPanel(it, ctx));
}

function main() {
  readStdin()
    .then((raw) => {
      const parsed = JSON.parse(raw);
      const items = Array.isArray(parsed?.items) ? parsed.items : [];
      const eggs = pickEasterEggs(items);
      const props = storyProps(items);

      const prompt = [
        "Cartoony illustration that matches the very distinct ORF ZiB studio look (NOT a generic newsroom).",
        "Camera framing: wide studio shot, anchor centered behind the desk, desk fills the lower half of frame.",
        "Color palette: dominant deep navy/midnight blue and cool cyan/blue lighting, with small crisp red accents. High-tech, clean, minimal.",
        "Set design cues (no logos):",
        "- a large CURVED wraparound video wall backdrop",
        "- the video wall prominently shows a panoramic Earth-from-space horizon band (blue glow) behind the anchor",
        "- vertical LED light columns/panels segmenting the backdrop",
        "- dark glossy reflective floor/riser with subtle blue light strips",
        "Desk design cues:",
        "- large oval/curved anchor desk with a glossy dark (glass-like) top",
        "- white/light-gray geometric base (faceted), with blue underglow",
        "- a thin horizontal red accent line near the desk edge",
        "Lighting: cool studio key light + blue ambient + subtle red rim accents.",
        "Style: 2D cartoon, crisp linework, soft shading, high detail, friendly and delightful.",
        "No logos, no watermarks.",
        "The studio's wraparound video wall MUST clearly reflect the specific news you pulled.",
        "Show 4–6 distinct panels/cards across the wall (one per story). Each panel must be clean and readable:",
        "- TOP: big bold all-caps text (1–2 words). Invent this; do not copy the ORF headline.",
        "- MIDDLE: smaller text (3–6 words) describing the story. Invent this.",
        "  - The two lines must not form a connected sentence.",
        "  - Avoid repeating the same words between the two lines.",
        "- BOTTOM: exactly 1–2 simple icons (no maps, no busy collages)",
        ...props.map((p) => `- ${p}`),
        "Add 3–4 subtle Easter eggs to reward close inspection (no logos):",
        ...eggs.map((e) => `- ${e}`),
        "Avoid sports imagery.",
      ].join("\n");

      process.stdout.write(prompt + "\n");
    })
    .catch((err) => {
      process.stderr.write(String(err?.stack ?? err));
      process.stderr.write("\n");
      process.exitCode = 1;
    });
}

main();


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)