/** * demo/showcase.ts * * Three-persona collaboration showcase for recording a mini product * video. An empty article gets turned into a compact research piece * on Transformer attention by three agents working in concert: * * - Bob writes "The recipe" (top): definition, block-math * formula, softmax explanation. Mid-cycle he fires a * chat citation tool call that adds the Vaswani 2017 * paper to the bibliography. * - Alice (hero window) writes "Intuition" (middle): an analogy * with inline $Q$ / $K$ / $V$, bolds a key phrase via the * in-editor bubble toolbar, then asks the chat agent to * rephrase the closing line (AgentRewrite typewriter). * At the end she scrolls to the top, slowly drags the * Hue slider (warm yellow -> cool blue, article retints * live including the neural-net banner), clicks * Publish, waits for the SSE pipeline to succeed, and * finally clicks "View published article" so the * recording ends on the real rendered output - the * "ship it" payoff. * - Carol writes "In Python" (bottom): intro line, link action * on "PyTorch" (points at the real * scaled_dot_product_attention docs), 8-line code * block, Vaswani closing line. * * Three authors (Hugging Face, MIT/ETH Zurich, Stanford), a neural * network banner, a live hue drag, three agent tool-calls (bubble * bold + chat rephrase + citation + link), and a real publish * (Publishing... Rendering HTML... Rasterizing embeds... * Published!) land in a ~60s budget. * * By default: * - Alice is the "hero" window (visible). This is what you record. * - Bob and Carol run headless. Their yjs cursors, comments and * edits appear in Alice's window as if real teammates joined. * * Flags: * --all-visible Launch Bob and Carol visibly too (debug layout). * --record Recording-friendly mode for Alice (no toolbar / * tabs / URL bar / automation banner, OS * fullscreen, persistent context). Bob and Carol * are forced headless. A 5s countdown gives you * time to start your screen recorder. * --capture macOS only. Implies --record. Automatically * starts `screencapture -v` before the demo and * stops it when the scenario ends. The resulting * .mov is saved under demo/recordings/. First run * triggers a Screen Recording permission prompt; * grant it to `Terminal` (or iTerm). * * Prereqs: * - Frontend dev server on http://localhost:5678 * - Backend dev server on http://localhost:8080 */ import { runAlice } from "./alice.js"; import { runBob } from "./bob.js"; import { runCarol } from "./carol.js"; import { resetDoc } from "./lib/editor-actions.js"; import { PERSONAS } from "./personas.js"; import { killLeftoverChromiums, launchPersona, shutdownAllPersonas, type PersonaHandle, } from "./lib/chromium.js"; import { buildRecordingPath, recordingCountdown, startScreenRecording, type ScreenRecording, } from "./lib/recording.js"; import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js"; import type { Page } from "playwright"; // --------------------------------------------------------------------------- // Post-parallel doc verification // --------------------------------------------------------------------------- /** * Expected text fragments (substrings, case-sensitive) for each slot * a persona is supposed to own after the parallel phase. Strings not * positions, because Yjs syncs can still be in flight when we read. * * If a fragment is missing in its expected slot, or shows up in a * DIFFERENT section's slot, that's a collision - the kind of bug * user reports as "agents stepping on each other's toes". */ const SLOT_EXPECTATIONS: Array<{ heading: string; slot: number; mustInclude: string; owner: "alice" | "bob" | "carol"; }> = [ // Bob - "The recipe" (4 slots: def, block-math, softmax, multi-head) { heading: "The recipe", slot: 0, mustInclude: "Attention lets a token", owner: "bob", }, // slot 1 is a atom; checked separately below. { heading: "The recipe", slot: 2, mustInclude: "softmax turns those similarity scores", owner: "bob", }, { heading: "The recipe", slot: 3, mustInclude: "multi-head", owner: "bob", }, // Alice - "Intuition" { heading: "Intuition", slot: 0, mustInclude: "Imagine each token asking", owner: "alice", }, { heading: "Intuition", slot: 1, // Post-rephrase. If the rephrase did not run we still accept the // original wording so we don't false-positive on a failed chat // action (different bug category). mustInclude: "every token", owner: "alice", }, { heading: "Intuition", slot: 2, mustInclude: "fully differentiable", owner: "alice", }, // Carol - "In Python" { heading: "In Python", slot: 0, mustInclude: "eight lines of PyTorch", owner: "carol", }, // slot 1 is the code block; tested separately below { heading: "In Python", slot: 2, mustInclude: "Every modern LLM", owner: "carol", }, ]; /** * Inspect the shared Y.XmlFragment directly (no browser needed) * and return a flat "sections" view matching what the old * DOM-based verifier produced, so `SLOT_EXPECTATIONS` keeps * working unchanged. */ async function verifyFinalDoc(bob: YjsPersona): Promise { // We import yjs lazily here to avoid a top-level dep circle with // `./lib/yjs-client.ts`; the verifier only runs once at the end // of a demo so the extra dynamic import is cheap. const Y = await import("yjs"); const sections: Array<{ heading: string; slots: string[]; slotTags: string[]; }> = []; let current: { heading: string; slots: string[]; slotTags: string[]; } | null = null; // textContent for a Y.XmlElement: concatenate every descendant // Y.XmlText, skipping inline atom nodes (inlineMath, citation) // which have no textContent of their own but carry meaningful // attributes. We still keep their node name in the tag so the // flat text search below can spot a citation chip by inference. // Y.XmlText.toString() serializes marks as XML tags (`foo`, // `bar`), which breaks "expected substring" // checks like `"eight lines of PyTorch"` when a mark like link wraps // the trailing word. We read the Delta format instead and only keep // the `insert` text values: gives us the plain concatenated text a // reader would see, mark attributes ignored. const xmlTextToPlain = (t: Y.XmlText): string => { const delta = t.toDelta() as Array<{ insert?: unknown }>; let out = ""; for (const op of delta) { if (typeof op.insert === "string") out += op.insert; } return out; }; const textOf = (el: Y.XmlElement): string => { // Atom nodes (`blockMath`, `inlineMath`) carry their whole // payload on the `latex` attribute and have no children, so // the child-walker below would see an empty slot. Surface the // LaTeX verbatim when the slot IS an atom. if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") { const latex = el.getAttribute("latex"); return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : ""; } let out = ""; const visit = (node: Y.XmlElement | Y.XmlText) => { if (node instanceof Y.XmlText) { out += xmlTextToPlain(node); } else if (node instanceof Y.XmlElement) { if (node.nodeName === "citation") { const label = node.getAttribute("label"); if (typeof label === "string") out += label; } else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") { const latex = node.getAttribute("latex"); if (typeof latex === "string") out += latex; } else { for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText); } } }; for (const child of el.toArray()) { visit(child as Y.XmlElement | Y.XmlText); } return out.replace(/\s+/g, " ").trim(); }; for (const child of bob.fragment.toArray()) { if (!(child instanceof Y.XmlElement)) continue; if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) { if (current) sections.push(current); current = { heading: textOf(child), slots: [], slotTags: [], }; } else if (current) { const tagMap: Record = { paragraph: "P", codeBlock: "PRE", heading: "H", bibliography: "BIB", blockMath: "MATH", }; current.slots.push(textOf(child)); current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase()); } } if (current) sections.push(current); const flatText = sections .flatMap((s) => [s.heading, ...s.slots]) .join(" ") .replace(/\s+/g, " ") .trim(); const report = { sections, flatText }; if (!report.sections.length) { console.warn("[verify] empty fragment, nothing to verify"); return; } console.log("[verify] ----- doc layout -----"); for (const s of report.sections) { console.log(`[verify] ## ${s.heading} (${s.slots.length} slots)`); s.slots.forEach((txt, i) => { const tag = s.slotTags[i]; const trimmed = txt.length > 80 ? txt.slice(0, 80) + "..." : txt; console.log(`[verify] [${i}] <${tag}> ${trimmed || "(empty)"}`); }); } const anomalies: string[] = []; for (const exp of SLOT_EXPECTATIONS) { const section = report.sections.find((s) => s.heading.toLowerCase().includes(exp.heading.toLowerCase()), ); if (!section) { anomalies.push(`missing section: "${exp.heading}"`); continue; } const slotText = section.slots[exp.slot] ?? ""; if (!slotText.includes(exp.mustInclude)) { // Did the expected fragment land elsewhere in the doc? const leakedInto = report.sections .flatMap((s) => s.slots.map((txt, i) => ({ heading: s.heading, slot: i, text: txt, })), ) .find( (loc) => loc.text.includes(exp.mustInclude) && !(loc.heading === section.heading && loc.slot === exp.slot), ); if (leakedInto) { anomalies.push( `${exp.owner}: "${exp.mustInclude}" expected in ` + `${exp.heading}[${exp.slot}] but landed in ` + `${leakedInto.heading}[${leakedInto.slot}] ` + `- collision with neighbour section`, ); } else if (report.flatText.includes(exp.mustInclude)) { anomalies.push( `${exp.owner}: "${exp.mustInclude}" present in doc but not in ` + `${exp.heading}[${exp.slot}] (slot has "${slotText.slice(0, 40)}...")`, ); } else { anomalies.push( `${exp.owner}: "${exp.mustInclude}" missing from the doc entirely`, ); } } } // Code block sanity check: "In Python" slot 1 should be a
 /
  // codeblock node, and contain "def attention". Anything else means
  // Carol's code got absorbed into the wrong node.
  const inPython = report.sections.find((s) =>
    s.heading.toLowerCase().includes("in python"),
  );
  if (inPython) {
    const codeTag = inPython.slotTags[1];
    const codeText = inPython.slots[1] ?? "";
    if (codeTag !== "PRE" && !codeText.includes("def attention")) {
      anomalies.push(
        `carol: code block in "In Python"[1] not recognized ` +
          `(got tag=${codeTag}, text="${codeText.slice(0, 40)}...")`,
      );
    }
  }

  // Block math sanity check: "The recipe" slot 1 should be a
  //  atom carrying the attention formula. We grep the
  // LaTeX source for the two signature substrings the formula
  // always contains ("Attention" + "softmax") so a typo in the
  // source string doesn't silently pass.
  const recipe = report.sections.find((s) =>
    s.heading.toLowerCase().includes("the recipe"),
  );
  if (recipe) {
    const mathTag = recipe.slotTags[1];
    const mathText = recipe.slots[1] ?? "";
    const looksLikeFormula =
      mathText.includes("Attention") && mathText.includes("softmax");
    if (mathTag !== "MATH" || !looksLikeFormula) {
      anomalies.push(
        `bob: block math in "The recipe"[1] not recognized ` +
          `(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`,
      );
    }
  }

  // Bibliography should exist (Bob's citation action creates it).
  if (!report.flatText.includes("Vaswani")) {
    anomalies.push("bob: citation 'Vaswani' never landed in the doc");
  }

  if (anomalies.length === 0) {
    console.log("[verify] OK - no slot collisions detected");
  } else {
    console.log(`[verify] ${anomalies.length} anomalie(s) detected:`);
    for (const a of anomalies) console.log(`[verify]   - ${a}`);
  }
}

interface CliArgs {
  url: string;
  speed: number;
  allVisible: boolean;
  /**
   * Recording mode for Alice: chrome-less app window, true OS
   * fullscreen, automation banner suppressed, and Bob/Carol forced
   * headless so they never appear in the capture. Includes a short
   * countdown before the demo starts so you can hit "record" on
   * QuickTime / Loom / OBS.
   */
  record: boolean;
  /**
   * macOS-only: auto-record the main display with `screencapture -v`
   * while the demo runs. Implies record mode. Output goes to
   * demo/recordings/.
   */
  capture: boolean;
  /**
   * Recording duration in seconds (passed to `screencapture -V`).
   *
   * WHY this exists: sending SIGINT/SIGTERM/SIGHUP to
   * `screencapture -v` aborts the capture WITHOUT flushing the .mov.
   * The only reliable way to get a playable file is to let the
   * `-V ` timer elapse naturally. So we size this to
   * "demo duration + a few seconds of outro", and the script blocks
   * on the timer before exiting.
   *
   * Default 50s covers our ~45s demo + a ~5s outro on the published
   * article (user wants the browser to quit shortly after the
   * publish view, not linger for 30s). Bump if you expand the
   * scenario.
   */
  captureDuration: number;
}

function parseArgs(argv: string[]): CliArgs {
  const args: CliArgs = {
    url: "http://localhost:5678",
    speed: 2,
    allVisible: false,
    record: false,
    capture: false,
    captureDuration: 50,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--url") args.url = argv[++i];
    else if (a === "--speed") args.speed = Number(argv[++i]);
    else if (a === "--all-visible") args.allVisible = true;
    else if (a === "--record") args.record = true;
    else if (a === "--capture") args.capture = true;
    else if (a === "--capture-duration")
      args.captureDuration = Number(argv[++i]);
  }
  // --capture only makes sense alongside --record.
  if (args.capture) args.record = true;
  return args;
}

async function run() {
  const args = parseArgs(process.argv.slice(2));
  console.log(
    `[showcase] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible} record=${args.record}`,
  );

  // Nuke any orphaned Chromium left over from a previous run (e.g.
  // demo killed with Ctrl+C before it could close its browsers).
  // Without this we get stale Yjs connections / extra windows
  // cluttering the screen.
  killLeftoverChromiums();

  // Handles initialized lazily; kept in an array so SIGINT /
  // finally can nuke every browser that did get launched, even if
  // a later launch threw before we got to `Promise.all(runAlice,
  // ...)`.
  const handles: Array = [];
  // Bob + Carol are now Node-side Yjs peers (no Chromium); kept
  // separately so SIGINT / finally can tear their provider sockets
  // down cleanly alongside the browsers.
  const yjsPeers: YjsPersona[] = [];
  let recording: ScreenRecording | null = null;
  let alreadyCleanedUp = false;

  const cleanup = async (reason: string) => {
    if (alreadyCleanedUp) return;
    alreadyCleanedUp = true;
    console.log(`[showcase] cleanup (${reason})`);
    if (recording) {
      try {
        recording.abort();
      } catch {
        /* ignore */
      }
      recording = null;
    }
    for (const peer of yjsPeers) {
      try {
        peer.destroy();
      } catch {
        /* provider already closed */
      }
    }
    await shutdownAllPersonas(handles);
    console.log("[showcase] done.");
  };

  // Signal handlers: no matter the mode, Ctrl+C and SIGTERM always
  // trigger full cleanup so the user never ends up with orphan
  // Chromium windows consuming CPU / holding onto the Yjs server
  // connection.
  const onSignal = (sig: string) => {
    cleanup(sig)
      .catch(() => {})
      .finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
  };
  process.once("SIGINT", onSignal);
  process.once("SIGTERM", onSignal);

  try {
    // Bob and Carol are ALWAYS headless. Showing them visibly (even
    // outside record mode) steals focus from Alice's typing,
    // causes keystrokes to race with window-manager animations,
    // and generally makes the demo unreliable. --all-visible only
    // controls Alice's layout now.
    const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;

    const alice = await launchPersona(args.url, {
      persona: PERSONAS.alice,
      headless: false,
      windowPosition: alicePos,
      fullscreen: !args.allVisible && !args.record,
      recordMode: args.record,
    });
    handles.push(alice);

    // Relay demo-related browser console messages to the Node log so
    // we can diagnose silent frontend errors (e.g. "replace target
    // not found") during a --capture run without having to open
    // DevTools by hand.
    alice.page.on("console", (msg) => {
      const text = msg.text();
      if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) {
        console.log(`[alice:console ${msg.type()}] ${text}`);
      }
    });
    console.log("[showcase] alice up");

    // Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
    // Carol join, so they connect to a clean slate (no leftover
    // banner / body / title from a previous demo run). Idempotent:
    // a no-op on a fresh server.
    console.log("[showcase] resetting shared document");
    await resetDoc(alice.page);

    // Bob and Carol connect as Node-side Yjs peers instead of
    // full Chromium browsers. They appear in Alice's editor via
    // normal collaboration sync (authors list, text edits,
    // citations) without any DOM typing or PM position race.
    // URL transformation: http:// -> ws:// on the /collab path.
    const wsUrl = args.url.replace(/^http/, "ws") + "/collab";
    const bob = await connectPersona({
      url: wsUrl,
      docName: "default",
      user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color },
    });
    yjsPeers.push(bob);
    console.log("[showcase] bob up (yjs peer)");

    const carol = await connectPersona({
      url: wsUrl,
      docName: "default",
      user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color },
    });
    yjsPeers.push(carol);
    console.log("[showcase] carol up (yjs peer)");

    // Auto screen recording takes precedence over the manual
    // countdown: if --capture is on, we start `screencapture -v`
    // immediately and give the first frame a beat to settle
    // before content starts typing.
    if (args.capture) {
      const outputPath = buildRecordingPath();
      recording = await startScreenRecording(outputPath, args.captureDuration);
      if (recording) {
        console.log(
          `[showcase] screen recording started (${args.captureDuration}s max) -> ${recording.outputPath}`,
        );
        await new Promise((r) => setTimeout(r, 1500));
      } else {
        console.warn("[showcase] falling back to manual countdown");
      }
    }
    if (args.record && !recording) {
      await recordingCountdown(5);
    }

    // T0 marks the start of the actual demo content (after the
    // browsers and Yjs reset). Useful to measure the perceived
    // "video length" independently of Playwright cold-start time.
    const T0 = Date.now();
    const stamp = (tag: string) =>
      console.log(
        `[showcase] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`,
      );
    stamp("demo phase start");

    // Bob / Carol settle first; Alice keeps running through rephrase
    // + hue + publish. We wait for Bob and Carol explicitly so we
    // can inspect the shared doc from Bob's editor page WHILE Alice
    // is still wrapping up. That way the diagnostic reads the post-
    // parallel state (no publish navigation has hijacked Bob's page
    // yet) and we can log any collision BEFORE the screencast ends.
    const alicePromise = runAlice(alice.page, args.speed)
      .then(() => stamp("alice done"))
      .catch((err) => console.error("[alice] error:", err));
    const bobPromise = runBob(bob, args.speed)
      .then(() => stamp("bob done"))
      .catch((err) => console.error("[bob] error:", err));
    const carolPromise = runCarol(carol, args.speed)
      .then(() => stamp("carol done"))
      .catch((err) => console.error("[carol] error:", err));

    await Promise.all([bobPromise, carolPromise]);

    // Wait for Alice too: her rephrase typewriter + slot 2 wrap-up
    // keeps writing AFTER Bob/Carol finish. Running verify before
    // Alice is done used to show phantom "missing fragments" simply
    // because the rephrase animation had not landed yet.
    await alicePromise;
    stamp("all personas finished");

    // Post-parallel doc verification: read the shared Y.XmlFragment
    // directly from Bob's still-connected Yjs peer. Alice's browser
    // has navigated to the published output by this point, but the
    // Yjs provider keeps syncing regardless of which page is on
    // screen, so Bob's handle always reflects the final CRDT state.
    try {
      await verifyFinalDoc(bob);
    } catch (err) {
      console.warn(
        "[verify] aborted:",
        err instanceof Error ? err.message : err,
      );
    }

    // Block on the screencapture timer so the user gets a complete,
    // playable .mov. Extra time after "all personas finished"
    // looks fine in the recording (final state of the editor,
    // good outro frame). If the demo finished before the -V timer
    // elapses, we wait here; otherwise this resolves immediately.
    if (recording) {
      const remainingMs = Math.max(0, recording.finishesAt - Date.now());
      if (remainingMs > 0) {
        const secs = (remainingMs / 1000).toFixed(1);
        console.log(
          `[showcase] waiting ${secs}s for screencapture to flush the .mov...`,
        );
      }
      await recording.waitForFinish();
      console.log(`[showcase] recording saved: ${recording.outputPath}`);
      // Don't let `cleanup()` kill screencapture after it finished
      // cleanly.
      recording = null;
    }
  } finally {
    // Always close browsers, regardless of mode or whether the demo
    // succeeded. Previously dev mode kept browsers open "for
    // inspection", but orphan Chromium windows after every run are
    // more annoying than the occasional loss of a final doc state.
    await cleanup("run complete");
  }
}

run().catch(async (err) => {
  console.error("[showcase] fatal:", err);
  // Even on fatal errors, guarantee no leftover Chromium /
  // screencapture.
  try {
    killLeftoverChromiums();
  } catch {
    /* ignore */
  }
  process.exit(1);
});