diff --git a/docs/index.html b/docs/index.html index 4d507859..239f3a44 100644 --- a/docs/index.html +++ b/docs/index.html @@ -80,20 +80,20 @@

Your AI. Your machine. Your
Row-Bot ยท Real app captureRecorded in-product
- + Local-model research resolving into a populated knowledge graph of connected nodes and edges in Row-Bot.
diff --git a/docs/media/landing-story/clips/automate.mp4 b/docs/media/landing-story/clips/automate.mp4 index 5d6d9fb8..18c065e2 100644 Binary files a/docs/media/landing-story/clips/automate.mp4 and b/docs/media/landing-story/clips/automate.mp4 differ diff --git a/docs/media/landing-story/clips/automate.webm b/docs/media/landing-story/clips/automate.webm index b337cbbd..dec10c56 100644 Binary files a/docs/media/landing-story/clips/automate.webm and b/docs/media/landing-story/clips/automate.webm differ diff --git a/docs/media/landing-story/clips/create.mp4 b/docs/media/landing-story/clips/create.mp4 index 2f4182a2..8bafeed8 100644 Binary files a/docs/media/landing-story/clips/create.mp4 and b/docs/media/landing-story/clips/create.mp4 differ diff --git a/docs/media/landing-story/clips/create.webm b/docs/media/landing-story/clips/create.webm index 1777a33c..f0d79ae4 100644 Binary files a/docs/media/landing-story/clips/create.webm and b/docs/media/landing-story/clips/create.webm differ diff --git a/docs/media/landing-story/clips/research.mp4 b/docs/media/landing-story/clips/research.mp4 index 992ca1aa..b67a65fb 100644 Binary files a/docs/media/landing-story/clips/research.mp4 and b/docs/media/landing-story/clips/research.mp4 differ diff --git a/docs/media/landing-story/clips/research.webm b/docs/media/landing-story/clips/research.webm index 9da1c2d2..bba7542e 100644 Binary files a/docs/media/landing-story/clips/research.webm and b/docs/media/landing-story/clips/research.webm differ diff --git a/docs/media/landing-story/clips/ship.mp4 b/docs/media/landing-story/clips/ship.mp4 index 32d1199a..7b955d96 100644 Binary files a/docs/media/landing-story/clips/ship.mp4 and b/docs/media/landing-story/clips/ship.mp4 differ diff --git a/docs/media/landing-story/clips/ship.webm b/docs/media/landing-story/clips/ship.webm index 784d5b8a..b7194a7e 100644 Binary files a/docs/media/landing-story/clips/ship.webm and b/docs/media/landing-story/clips/ship.webm differ diff --git a/docs/media/landing-story/manifest.json b/docs/media/landing-story/manifest.json index a4398fc7..f264b488 100644 --- a/docs/media/landing-story/manifest.json +++ b/docs/media/landing-story/manifest.json @@ -1,78 +1,87 @@ { "schema": 1, - "story_id": "sovereignty-workbench-v1", - "run_id": "20260921T230035Z-f3ed05", + "story_id": "react-workbench-v1", + "run_id": "20261002T015343Z-6e392a", "review_status": "approved", - "reviewed_at": "2026-09-23T10:37:41Z", + "reviewed_at": "2026-10-02T01:53:43Z", "recording_receipt": "recording-receipt.json", + "production": { + "app": "Row-Bot 5.0.0, the real app process and its React client at /app-v2/", + "method": "Scripted Playwright capture (scripts/docs/capture_landing_media.py) in Microsoft Edge at 1440x810 CSS px, dark appearance, on an isolated temporary profile seeded with fictional demo data (a neighbourhood solar co-op); no real user data, accounts, names or paths", + "models": "No model ran. The local runtime is the docs capture's display-only stand-in on loopback: it lists qwen3.8:27b, the local model the owner runs, and runs nothing. GPT-5.6 Sol via ChatGPT / Codex is display-only: the harness tells the client it is connected, and nothing is connected or called", + "outward_actions": "None: the harness answers the Ship clip's Approve request itself, so no email is sent", + "pointer": "Drawn by the harness to show where the scripted clicks land", + "stills": "Rendered at 2x and downscaled with Lanczos to 1440x810 WebP", + "clips": "Browser screencast frames resampled to 30 fps; VP9 WebM and H.264 MP4 (faststart), no audio" + }, "assets": [ { "scene_id": "hero-app", "still": "screenshots/research.webp", - "sha256": "68310dfdebba4089fbadb43f0eae000e966b975a6f1586cb33c4b1c170f0d90c", - "alt": "Row-Bot using a local model for research and resolving the findings into connected knowledge." + "sha256": "21f0ddffcbaa7a70b75de21b468990e11048f8a530fcca47d53a4d4f6fa99e17", + "alt": "Row-Bot's Knowledge graph after local-model research, with the co-op's memory and its connections highlighted." }, { "scene_id": "research-local", "still": "screenshots/research.webp", - "sha256": "68310dfdebba4089fbadb43f0eae000e966b975a6f1586cb33c4b1c170f0d90c", + "sha256": "21f0ddffcbaa7a70b75de21b468990e11048f8a530fcca47d53a4d4f6fa99e17", "clip": "clips/research.webm", - "clip_sha256": "97e8e0238875d785da1d3ecab244c41705e10257d17b4f37cbd1d249e62483e1", + "clip_sha256": "66f945575c014f307b24667aae7da64d4969a4b4224d5f3c06951a6dd4374100", "clip_fallback": "clips/research.mp4", - "clip_fallback_sha256": "32aa733d8e7f63be8bd7069bb1ad99c771c2fb9e9a69896eb28b08342e567b1f", - "alt": "Local-model research with source activity followed by a populated node-and-edge knowledge graph." + "clip_fallback_sha256": "fb222cf2667d296c8ca675578432f45e63221f6a51484cecfef1812dae5867dd", + "alt": "A local-model conversation with its source trace, followed by the Knowledge graph settling into connected memories." }, { "scene_id": "synthesis-sol", "still": "screenshots/create.webp", - "sha256": "d8fc8adf27a1b5b73731747fe679e2c8712a999f2366b2dcc5e1a95852cc1cee", + "sha256": "c532a3d8e9d326162253497e90a347416ecc559eb54b789133377005a6c74dd8", "clip": "clips/create.webm", - "clip_sha256": "b4429bfbd43f6be42bd072af7273d563e989590187365fa4207292e7fad9b466", + "clip_sha256": "37c3142237e041bf2a431208d3f97a679718e40b3095913075c7c25a29caeaca", "clip_fallback": "clips/create.mp4", - "clip_fallback_sha256": "f7cc3f8b29c4fdff22649a321e34584cdb2bc97ded7038a5cc5bf7410e9a826c", - "alt": "An explicit frontier-model handoff followed by a real editable Designer artifact." + "clip_fallback_sha256": "be7637a203f56c8521dd9d329ac03bec93136f94a2d1ddbd56f01311c419145a", + "alt": "A conversation handed to a frontier model, GPT-5.6 Sol, with an editable landing page in its Design panel." }, { "scene_id": "knowledge-control", "still": "screenshots/research.webp", - "sha256": "68310dfdebba4089fbadb43f0eae000e966b975a6f1586cb33c4b1c170f0d90c", + "sha256": "21f0ddffcbaa7a70b75de21b468990e11048f8a530fcca47d53a4d4f6fa99e17", "clip": "clips/research.webm", - "clip_sha256": "97e8e0238875d785da1d3ecab244c41705e10257d17b4f37cbd1d249e62483e1", - "alt": "A genuine Row-Bot knowledge graph with many connected nodes and edges from the research workspace." + "clip_sha256": "66f945575c014f307b24667aae7da64d4969a4b4224d5f3c06951a6dd4374100", + "alt": "A Row-Bot knowledge graph of 103 connected memories, with one memory's connections labelled." }, { "scene_id": "designer-output", "still": "screenshots/create.webp", - "sha256": "d8fc8adf27a1b5b73731747fe679e2c8712a999f2366b2dcc5e1a95852cc1cee", + "sha256": "c532a3d8e9d326162253497e90a347416ecc559eb54b789133377005a6c74dd8", "clip": "clips/create.webm", - "clip_sha256": "b4429bfbd43f6be42bd072af7273d563e989590187365fa4207292e7fad9b466", - "alt": "A real editable launch visual open in Row-Bot Designer Studio." + "clip_sha256": "37c3142237e041bf2a431208d3f97a679718e40b3095913075c7c25a29caeaca", + "alt": "An editable landing page open in the conversation's Design panel, its heading then selected for editing." }, { "scene_id": "workflow-repeat", "still": "screenshots/automate.webp", - "sha256": "c980145f3439741e79b567764ed0423f3ae57af1d4d7069d21ae65c9639f4e8e", + "sha256": "1a48703370bd98c111ce0a1fa95f8dc89e15ba02df134fab46c4c1e1c516bf0d", "clip": "clips/automate.webm", - "clip_sha256": "5cdadc636b961ad471fecd3ab58413211948871741099eecb5f36235ad3fcccf", + "clip_sha256": "460b3b3a72c9bfcf95ec441a45fc417f70a4b58b4df60a4a81dc81103ae5ffef", "clip_fallback": "clips/automate.mp4", - "clip_fallback_sha256": "bce62f19eaac16156a18834884679a251d96b671a900ed4044e2cecde31374f3", - "alt": "A local-model workflow being configured and saved with external delivery disabled." + "clip_fallback_sha256": "67ac6fbb2398dbff2459c13f9ed63feb9e62907b25c50ea9af9e589815997bbd", + "alt": "Workflow delivery kept in the app with no outside channels, a workflow's local model saved, then its retained run history." }, { "scene_id": "approval-boundary", "still": "screenshots/ship.webp", - "sha256": "37c1f310ed1201f05ee916d5d7709897aee1288bfa062fbecd904b9e667bdeb6", + "sha256": "98214f35cfdc1b9eba3c215e79a8128d28739a091d36140450be0f1ec27c2c2a", "clip": "clips/ship.webm", - "clip_sha256": "12bb1ee4e1faf736bc66b4fd3428594afd78cf5a7c5d14f25ef3ab24a4495e60", + "clip_sha256": "b4732efdc66ccb0a89caa4e3e7f6bceba3e697ac0ca8547e85db7a429b18bc23", "clip_fallback": "clips/ship.mp4", - "clip_fallback_sha256": "7d72c19347d937f52fad94061e7104f5c7ab18d37e01f84f2cdfa8034a89a234", - "alt": "Row-Bot requesting approval before creating a reviewed local campaign artifact." + "clip_fallback_sha256": "1295b6b23d32244918f1d683a3fb86aee3657ade02017122a7d34c8b38cad777", + "alt": "Row-Bot waiting for approval before emailing a PDF to riverbend-board@example.com, then the approval being given." }, { "scene_id": "model-choice", "still": "screenshots/create.webp", - "sha256": "d8fc8adf27a1b5b73731747fe679e2c8712a999f2366b2dcc5e1a95852cc1cee", - "alt": "The model picker making the local and frontier model boundary explicit." + "sha256": "c532a3d8e9d326162253497e90a347416ecc559eb54b789133377005a6c74dd8", + "alt": "The composer's model pill showing GPT-5.6 Sol as a cloud model, beside the conversation's Design panel." }, { "scene_id": "mobile-shell", @@ -117,7 +126,10 @@ "source_commit": "b614d94f", "source_sha256": "f1d4b3ade0d97361163bfd7cb5832b94197671ef922da119b8b0b698187718ca", "source_duration_seconds": 12.042, - "kept_segment_seconds": [5.333, 9.916], + "kept_segment_seconds": [ + 5.333, + 9.916 + ], "method": "VP9/WebM packet remux without re-encoding; alpha preserved", "reason": "Remove the violet opening orbs and retain the cyan Automate motion", "result_sha256": "00344446d1e9bb4624e7ed4cbbfadfc0972d905372d3fb4fa3d7f2e2a1003369", diff --git a/docs/media/landing-story/recording-receipt.json b/docs/media/landing-story/recording-receipt.json index e688257f..cab6780f 100644 --- a/docs/media/landing-story/recording-receipt.json +++ b/docs/media/landing-story/recording-receipt.json @@ -1,144 +1,134 @@ { "schema": 1, - "story_id": "sovereignty-workbench-v1", - "editing": "normal-speed editorial cuts with four-frame dissolves; sharpened encodes from matching reviewed source recordings; no global acceleration; Ship approval remains unmodified from the test-machine recording", + "story_id": "react-workbench-v1", + "run_id": "20261002T015343Z-6e392a", + "editing": "normal-speed editorial cuts with four-frame dissolves; scripted takes of the real app, no acceleration", + "production": { + "app": "Row-Bot 5.0.0, the real app process and its React client at /app-v2/", + "method": "Scripted Playwright capture (scripts/docs/capture_landing_media.py) in Microsoft Edge at 1440x810 CSS px, dark appearance, on an isolated temporary profile seeded with fictional demo data (a neighbourhood solar co-op); no real user data, accounts, names or paths", + "models": "No model ran. The local runtime is the docs capture's display-only stand-in on loopback: it lists qwen3.8:27b, the local model the owner runs, and runs nothing. GPT-5.6 Sol via ChatGPT / Codex is display-only: the harness tells the client it is connected, and nothing is connected or called", + "outward_actions": "None: the harness answers the Ship clip's Approve request itself, so no email is sent", + "pointer": "Drawn by the harness to show where the scripted clicks land", + "stills": "Rendered at 2x and downscaled with Lanczos to 1440x810 WebP", + "clips": "Browser screencast frames resampled to 30 fps; VP9 WebM and H.264 MP4 (faststart), no audio" + }, "clips": { "research": { - "source_name": "1.mp4", - "source_sha256": "975367434a6e5f9c1349a9e48d8474385d752fa6214a068fd00f73c6fc40cd84", - "source_duration_seconds": 47.133, + "source": "Screencast of the real Row-Bot 5.0.0 React client", + "source_frames": 501, "segments_seconds": [ [ - 3.0, - 8.6 + 0.0, + 3.526 ], [ - 44.0, - 47.05 + 3.768, + 9.775 ] ], - "duration_seconds": 8.533, + "duration_seconds": 9.4, "dimensions": [ 1440, 810 ], "fps": 30, "webm": "media/landing-story/clips/research.webm", - "webm_sha256": "97e8e0238875d785da1d3ecab244c41705e10257d17b4f37cbd1d249e62483e1", - "webm_bytes": 602239, + "webm_sha256": "66f945575c014f307b24667aae7da64d4969a4b4224d5f3c06951a6dd4374100", + "webm_bytes": 791002, "mp4": "media/landing-story/clips/research.mp4", - "mp4_sha256": "32aa733d8e7f63be8bd7069bb1ad99c771c2fb9e9a69896eb28b08342e567b1f", - "mp4_bytes": 339781, + "mp4_sha256": "fb222cf2667d296c8ca675578432f45e63221f6a51484cecfef1812dae5867dd", + "mp4_bytes": 749915, "poster": "media/landing-story/screenshots/research.webp", - "poster_sha256": "68310dfdebba4089fbadb43f0eae000e966b975a6f1586cb33c4b1c170f0d90c", - "poster_bytes": 89492, - "editorial_note": "Reviewed source frames retained at natural 30 fps; four-frame dissolves join selected actions." + "poster_sha256": "21f0ddffcbaa7a70b75de21b468990e11048f8a530fcca47d53a4d4f6fa99e17", + "poster_bytes": 67816, + "editorial_note": "Two takes at normal speed joined by a four-frame dissolve: the local-model conversation's source trace, then the Knowledge graph settling and its co-op memory highlighted." }, "create": { - "source_name": "2.mp4", - "source_sha256": "6624ace4e8f218a934899f6a9ff81d0208b2b886e68c75988e55a103a01f8055", - "source_duration_seconds": 50.0, + "source": "Screencast of the real Row-Bot 5.0.0 React client", + "source_frames": 325, "segments_seconds": [ [ - 2.0, - 4.5 - ], - [ - 12.0, - 14.5 - ], - [ - 22.5, - 25.5 - ], - [ - 47.0, - 48.5 + 0.0, + 9.445 ] ], - "duration_seconds": 9.1, + "duration_seconds": 9.433, "dimensions": [ 1440, 810 ], "fps": 30, "webm": "media/landing-story/clips/create.webm", - "webm_sha256": "b4429bfbd43f6be42bd072af7273d563e989590187365fa4207292e7fad9b466", - "webm_bytes": 733183, + "webm_sha256": "37c3142237e041bf2a431208d3f97a679718e40b3095913075c7c25a29caeaca", + "webm_bytes": 285946, "mp4": "media/landing-story/clips/create.mp4", - "mp4_sha256": "f7cc3f8b29c4fdff22649a321e34584cdb2bc97ded7038a5cc5bf7410e9a826c", - "mp4_bytes": 445845, + "mp4_sha256": "be7637a203f56c8521dd9d329ac03bec93136f94a2d1ddbd56f01311c419145a", + "mp4_bytes": 276072, "poster": "media/landing-story/screenshots/create.webp", - "poster_sha256": "d8fc8adf27a1b5b73731747fe679e2c8712a999f2366b2dcc5e1a95852cc1cee", - "poster_bytes": 86514, - "editorial_note": "Reviewed source frames retained at natural 30 fps; four-frame dissolves join selected actions." + "poster_sha256": "c532a3d8e9d326162253497e90a347416ecc559eb54b789133377005a6c74dd8", + "poster_bytes": 70140, + "editorial_note": "One continuous take at normal speed: the hosted model shown in the picker, then the landing page opened for editing in the Design panel." }, "automate": { - "source_name": "3.mp4", - "source_sha256": "b52e3b3ff45824f452c3b20d76ca54a5831f5e4becfce86947154a7d639477cf", - "source_duration_seconds": 23.133, + "source": "Screencast of the real Row-Bot 5.0.0 React client", + "source_frames": 532, "segments_seconds": [ [ - 0.2, - 2.2 - ], - [ - 2.4, - 4.0 + 0.0, + 3.392 ], [ - 7.2, - 9.8 + 5.795, + 8.919 ], [ - 12.0, - 14.8 + 11.296, + 13.834 ] ], - "duration_seconds": 8.6, + "duration_seconds": 8.8, "dimensions": [ 1440, 810 ], "fps": 30, "webm": "media/landing-story/clips/automate.webm", - "webm_sha256": "5cdadc636b961ad471fecd3ab58413211948871741099eecb5f36235ad3fcccf", - "webm_bytes": 330721, + "webm_sha256": "460b3b3a72c9bfcf95ec441a45fc417f70a4b58b4df60a4a81dc81103ae5ffef", + "webm_bytes": 317374, "mp4": "media/landing-story/clips/automate.mp4", - "mp4_sha256": "bce62f19eaac16156a18834884679a251d96b671a900ed4044e2cecde31374f3", - "mp4_bytes": 213856, + "mp4_sha256": "67ac6fbb2398dbff2459c13f9ed63feb9e62907b25c50ea9af9e589815997bbd", + "mp4_bytes": 279166, "poster": "media/landing-story/screenshots/automate.webp", - "poster_sha256": "c980145f3439741e79b567764ed0423f3ae57af1d4d7069d21ae65c9639f4e8e", - "poster_bytes": 39512, - "editorial_note": "Reviewed source frames retained at natural 30 fps; four-frame dissolves join selected actions." + "poster_sha256": "1a48703370bd98c111ce0a1fa95f8dc89e15ba02df134fab46c4c1e1c516bf0d", + "poster_bytes": 66846, + "editorial_note": "Three takes at normal speed joined by four-frame dissolves: the workflow delivery defaults (no external channels), the workflow's local model saved, then its retained run history." }, "ship": { - "source_name": "4.mp4", - "source_sha256": "e4441c570bb7c4c27137ca61c40a0757597c6fadfb2145e1c6d2cbeb8d786d28", - "source_duration_seconds": 21.6, + "source": "Screencast of the real Row-Bot 5.0.0 React client", + "source_frames": 178, "segments_seconds": [ [ - 9.1, - 11.7 + 0.0, + 2.887 ] ], - "duration_seconds": 2.6, + "duration_seconds": 2.9, "dimensions": [ 1440, 810 ], "fps": 30, "webm": "media/landing-story/clips/ship.webm", - "webm_sha256": "12bb1ee4e1faf736bc66b4fd3428594afd78cf5a7c5d14f25ef3ab24a4495e60", - "webm_bytes": 125411, + "webm_sha256": "b4732efdc66ccb0a89caa4e3e7f6bceba3e697ac0ca8547e85db7a429b18bc23", + "webm_bytes": 107415, "mp4": "media/landing-story/clips/ship.mp4", - "mp4_sha256": "7d72c19347d937f52fad94061e7104f5c7ab18d37e01f84f2cdfa8034a89a234", - "mp4_bytes": 84508, + "mp4_sha256": "1295b6b23d32244918f1d683a3fb86aee3657ade02017122a7d34c8b38cad777", + "mp4_bytes": 107139, "poster": "media/landing-story/screenshots/ship.webp", - "poster_sha256": "37c1f310ed1201f05ee916d5d7709897aee1288bfa062fbecd904b9e667bdeb6", - "poster_bytes": 39676, - "editorial_note": "Reviewed source frames retained at natural 30 fps; four-frame dissolves join selected actions.", - "approval_moment_seconds": 2.25 + "poster_sha256": "98214f35cfdc1b9eba3c215e79a8128d28739a091d36140450be0f1ec27c2c2a", + "poster_bytes": 62198, + "editorial_note": "One continuous take at normal speed: the waiting email approval, then Approve.", + "approval_moment_seconds": 2.203 } } } diff --git a/docs/media/landing-story/screenshots/automate.webp b/docs/media/landing-story/screenshots/automate.webp index e67e4242..86e3de5a 100644 Binary files a/docs/media/landing-story/screenshots/automate.webp and b/docs/media/landing-story/screenshots/automate.webp differ diff --git a/docs/media/landing-story/screenshots/create.webp b/docs/media/landing-story/screenshots/create.webp index 8a04dca5..d380e917 100644 Binary files a/docs/media/landing-story/screenshots/create.webp and b/docs/media/landing-story/screenshots/create.webp differ diff --git a/docs/media/landing-story/screenshots/research.webp b/docs/media/landing-story/screenshots/research.webp index fa1cc12e..b67085a5 100644 Binary files a/docs/media/landing-story/screenshots/research.webp and b/docs/media/landing-story/screenshots/research.webp differ diff --git a/docs/media/landing-story/screenshots/ship.webp b/docs/media/landing-story/screenshots/ship.webp index 7ae70ccf..9262390b 100644 Binary files a/docs/media/landing-story/screenshots/ship.webp and b/docs/media/landing-story/screenshots/ship.webp differ diff --git a/scripts/docs/capture_landing_media.py b/scripts/docs/capture_landing_media.py new file mode 100644 index 00000000..a0422179 --- /dev/null +++ b/scripts/docs/capture_landing_media.py @@ -0,0 +1,1638 @@ +"""Capture the landing page's story stills and clips from the real React app. + + uv run --with imageio-ffmpeg python scripts/docs/capture_landing_media.py + uv run --with imageio-ffmpeg python scripts/docs/capture_landing_media.py --scenes ship + uv run python scripts/docs/capture_landing_media.py --serve + +Row-Bot runs against an isolated temporary profile seeded with a fictional +neighbourhood solar co-op: conversations with a tool trace, a knowledge graph, +local workflows with their runs, a landing page design bound to its +conversation and an outward email waiting for approval. The local model runtime +is the docs capture's display-only stand-in on loopback, so nothing runs a +model. The Create scene's hosted model is display-only too: the harness tells +the client that ChatGPT / Codex is connected; no account is connected or +called. In the Ship scene the harness answers the Approve request itself, so +nothing is sent. + +Each scene is driven with Playwright at 1440x810 in the dark appearance, with a +drawn pointer showing where the scripted clicks land. Stills are rendered at +twice the density and downscaled; clips are recorded from the browser's +screencast, resampled to 30 fps and encoded with ffmpeg (``imageio-ffmpeg``, +added with ``uv run --with``; it is not a project dependency). Output lands in +``docs/media/landing-story/``, with the manifest and recording receipt rewritten +to match (``--records`` rewrites only those); scratch stays under +``.tmp/landing-capture/``. ``--serve`` seeds and starts the app for a manual look +and stops it when ``.tmp/landing-capture/serve.stop`` appears. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import io +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +from collections import deque +from contextlib import ExitStack +from datetime import datetime, timedelta, timezone +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any, Callable + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.docs.capture_real_ui_screenshots import ( # noqa: E402 + _DemoOllamaHandler, + _free_port, + _launch_app, + _wait_ping, +) + +SCRATCH = ROOT / ".tmp" / "landing-capture" +MEDIA = ROOT / "docs" / "media" / "landing-story" +WIDTH, HEIGHT = 1440, 810 +FPS = 30 +DISSOLVE_FRAMES = 4 +SCENES = ("research", "create", "automate", "ship") + +RESEARCH_THREAD = "landing-research" +CREATE_THREAD = "landing-create" +SHIP_THREAD = "landing-ship" +DIGEST_THREAD = "landing-digest-runs" +DESIGN_ID = "landing-launch-page" +SHIP_APPROVAL_ID = "1a4d1a000001" +# The local model the footage shows, as the owner runs it, used by every local +# conversation and workflow. Display-only: nothing runs. (The picker sorts its +# local models by name, so a second one would be listed above it.) +LOCAL_MODELS = ("qwen3.8:27b",) +LOCAL_MODEL = f"model:ollama:{LOCAL_MODELS[0]}" +HOSTED_MODEL = "model:codex:gpt-5.6-sol" +CO_OP = "Riverbend Community Solar" +# The capture's clock: conversations, runs and the approval are dated around a +# late-morning "now", and the browser's clock runs from the same moment. +CLOCK_HOUR, CLOCK_MINUTE = 10, 40 + + +def _anchor() -> datetime: + return datetime.now().replace(hour=CLOCK_HOUR, minute=CLOCK_MINUTE, second=0, microsecond=0) + + +# Seeding (runs in its own process, bound to the capture profile) ------------- + +def _call(name: str, args: dict, call_id: str) -> dict: + return {"name": name, "args": args, "id": call_id, "type": "tool_call"} + + +def _conversations() -> list[dict]: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + def step(name: str, args: dict, call_id: str, result: str) -> list: + return [AIMessage(content="", tool_calls=[_call(name, args, call_id)]), + ToolMessage(content=result, name=name, tool_call_id=call_id)] + + return [ + { + "id": RESEARCH_THREAD, + "title": "Community solar research", + "minutes": 28, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content=( + "Research how community solar co-ops share savings with their members. " + "Use public sources, keep the evidence visible, and save what we learn to Knowledge." + )), + *step("search_memory", {"query": "Riverbend community solar"}, "r1", + "4 memories about Riverbend Community Solar"), + *step("duckduckgo_search", {"query": "community solar co-op member savings"}, "r2", + "8 results from public sources"), + *step("wikipedia", {"query": "community solar"}, "r3", + "3 articles: community solar, net metering, cooperatives"), + *step("read_url", {"url": "https://en.wikipedia.org/wiki/Net_metering"}, "r4", + "Net metering ยท encyclopedia article"), + *step("read_url", {"url": "https://en.wikipedia.org/wiki/Cooperative"}, "r5", + "Cooperative ยท encyclopedia article"), + *step("save_memory", {"subject": "Bill credits", "category": "concept"}, "r6", + "Saved Bill credits"), + *step("save_memory", {"subject": "Subscription model", "category": "concept"}, "r7", + "Saved Subscription model"), + *step("link_memories", {"subject": CO_OP}, "r8", + f"Linked 12 memories to {CO_OP}"), + AIMessage(content=( + "Here is what the public sources agree on:\n\n" + "- **Shared ownership**: members subscribe to, or buy, a share of one larger array " + "instead of fitting panels at home.\n" + "- **Bill credits**: each share's output shows up as a credit on the member's bill.\n" + "- **Subscribe or buy**: subscriptions avoid an upfront cost; bought shares pay back over time.\n" + "- **Renters can join**: no roof of their own is needed.\n" + "- **Local rules decide the credit**: confirm the calculation with the grid operator.\n\n" + "I saved 12 memories and linked them to *Riverbend Community Solar* in Knowledge." + )), + ], + }, + { + "id": CREATE_THREAD, + "title": "Launch page direction", + "minutes": 9, + "model": HOSTED_MODEL, + "messages": [ + HumanMessage(content=( + "Turn the research into a launch page for Riverbend Community Solar: a warm hero, " + "how shares work, and a clear invitation to join." + )), + *step("search_memory", {"query": "Riverbend community solar research"}, "c1", + "12 memories from Community solar research"), + *step("create_design", {"name": "Riverbend launch page", "mode": "landing"}, "c2", + "Created Riverbend launch page"), + AIMessage(content=( + "The launch page is open in the Design panel: a sunrise hero with the invitation to join, " + "three steps for how shares work, and what members get back. Edit any section directly, " + "or tell me what to change." + )), + ], + }, + { + "id": SHIP_THREAD, + "title": "Share the launch page", + "minutes": 2, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content="Who should review the launch page before it goes public?"), + *step("search_memory", {"query": "Riverbend board review"}, "s9", + "3 memories: Board chair, Board meets on the first Tuesday, Board pack"), + AIMessage(content=( + "The board reviews anything members will read. Their shared address, " + "riverbend-board@example.com, reaches all five board members." + )), + HumanMessage(content="Check the launch page against the research before we share it."), + *step("search_memory", {"query": "Riverbend launch page facts"}, "s0", + "9 memories behind the launch page"), + AIMessage(content=( + "It matches the research: shares, bill credits and renter access are all explained, " + "and every figure on the page comes from Knowledge. The open evening date is on it too." + )), + HumanMessage(content="Export it as a PDF and email it to the co-op board for review."), + *step("export_to_pdf", {"name": "riverbend-launch-page.pdf"}, "s1", + "Saved riverbend-launch-page.pdf (3 pages)"), + AIMessage( + content="The PDF is ready. Emailing it leaves this computer, so it waits for your approval.", + tool_calls=[_call("send_gmail_message", { + "to": ["riverbend-board@example.com"], + "subject": "Launch page for review", + "attachments": ["riverbend-launch-page.pdf"], + }, "s2")], + ), + ], + }, + { + "id": DIGEST_THREAD, + "title": "Weekly policy digest", + "minutes": 60 * 26, + "model": LOCAL_MODEL, + "thread_type": "workflow", + "messages": [ + HumanMessage(content="Check public sources for community solar rule changes and summarise them."), + AIMessage(content=( + "**Policy digest**\n\n- No change to how bill credits are calculated this week.\n" + "- The small-grants round is open until the end of the month.\n" + "- Two new questions for the grid connection FAQ." + )), + ], + }, + { + "id": "landing-agenda", + "title": "Board meeting agenda", + "minutes": 95, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content="Draft the agenda for Tuesday's board meeting."), + AIMessage(content=( + "1. Survey results: 14 suitable roofs\n2. Launch page review\n" + "3. Grant application timeline\n4. Open evening at Market Hall" + )), + ], + }, + { + "id": "landing-survey", + "title": "Rooftop survey checklist", + "minutes": 60 * 23, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content="Make a checklist for the rooftop survey day."), + AIMessage(content=( + "- Roof direction and shading\n- Access and safety\n" + "- Structural notes for the surveyor\n- Photos of each roof" + )), + ], + }, + { + "id": "landing-volunteers", + "title": "Volunteer onboarding notes", + "minutes": 60 * 24 * 3, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content="Summarise what new volunteers need to know."), + AIMessage(content=( + "Volunteers help at the open evening, on survey days and with member questions. " + "Training night covers all three." + )), + ], + }, + { + "id": "landing-grant", + "title": "Grant application outline", + "minutes": 60 * 24 * 8, + "model": LOCAL_MODEL, + "messages": [ + HumanMessage(content="Outline the small-grants application."), + AIMessage(content=( + "1. The co-op and who it serves\n2. The first array\n3. Costs the grant covers\n" + "4. Community benefit" + )), + ], + }, + ] + + +def _seed_conversations(anchor: datetime) -> None: + from row_bot import threads + + for item in _conversations(): + threads.create_thread( + item["title"], + thread_id=item["id"], + thread_type=item.get("thread_type", ""), + approval_mode="approve", + model_override=item["model"], + name_source="manual", + ) + threads.append_checkpoint_messages(item["id"], item["messages"]) + moment = (anchor - timedelta(minutes=item["minutes"])).isoformat() + with sqlite3.connect(threads.DB_PATH) as conn: + conn.execute( + "UPDATE thread_meta SET created_at = ?, updated_at = ? WHERE thread_id = ?", + (moment, moment, item["id"]), + ) + + +def _tasks_db() -> sqlite3.Connection: + return sqlite3.connect(Path(os.environ["ROW_BOT_DATA_DIR"]) / "tasks.db") + + +def _seed_workflows(anchor: datetime) -> None: + from row_bot import tasks + + digest = tasks.create_task( + "Weekly policy digest", + prompts=[ + "Check public sources for changes to community solar rules this week.", + "Summarise anything that affects Riverbend in five plain-language bullets.", + "Save new facts to Knowledge and link them to Riverbend Community Solar.", + ], + description="Tracks community solar rule changes every Monday and keeps each run for review.", + icon="๐Ÿ“ฐ", + schedule="weekly:monday:08:00", + model_override=LOCAL_MODEL, + safety_mode="block", + channels=[], + persistent_thread_id=DIGEST_THREAD, + ) + questions = tasks.create_task( + "Member questions round-up", + prompts=["Collect this week's member questions.", "Draft short answers for the FAQ."], + description="Gathers member questions into draft FAQ answers.", + icon="๐Ÿ’ฌ", + schedule="weekly:friday:16:00", + model_override=LOCAL_MODEL, + safety_mode="block", + channels=[], + ) + survey = tasks.create_task( + "Survey photo sorter", + prompts=["Sort the new roof survey photos by site.", "List any roof that needs a second visit."], + description="Files survey photos by roof and flags second visits.", + icon="๐Ÿ ", + schedule="daily:18:00", + model_override=LOCAL_MODEL, + safety_mode="block", + channels=[], + ) + tasks.create_task( + "Open evening reminder", + prompts=["Remind me to confirm the Market Hall booking."], + description="A reminder before the open evening.", + icon="๐Ÿ””", + schedule="weekly:wednesday:09:00", + notify_only=True, + notify_label="Confirm the Market Hall booking", + channels=[], + ) + + def run(task_id: str, thread: str, name: str, steps: int, days: float, message: str) -> None: + run_id = tasks._record_run_start(task_id, thread, steps, name, "bolt") + tasks._update_run_progress(run_id, steps) + tasks._finish_run(run_id, "completed", message) + started = anchor - timedelta(days=days) + with _tasks_db() as conn: + conn.execute( + "UPDATE task_runs SET started_at = ?, finished_at = ? WHERE id = ?", + (started.isoformat(), (started + timedelta(minutes=3)).isoformat(), run_id), + ) + + for week, note in enumerate(( + "Two rule updates summarised; 3 facts saved.", + "No changes this week.", + "Grant round opened; 2 facts saved.", + "One consultation found; 1 fact saved.", + "No changes this week.", + "Connection FAQ updated; 2 facts saved.", + "Credit rules unchanged; 1 fact saved.", + "Three updates summarised; 4 facts saved.", + )): + run(digest, DIGEST_THREAD, "Weekly policy digest", 3, 1.1 + 7 * week, note) + for week in range(5): + run(questions, f"landing-questions-{week}", "Member questions round-up", 2, 4.7 + 7 * week, + "Five questions answered in draft.") + for day in range(9): + run(survey, f"landing-survey-{day}", "Survey photo sorter", 2, 0.7 + day, "Photos filed by roof.") + with _tasks_db() as conn: + for task_id, days in ((digest, 1.1), (questions, 4.7), (survey, 0.7)): + conn.execute("UPDATE tasks SET last_run = ? WHERE id = ?", + ((anchor - timedelta(days=days)).isoformat(), task_id)) + + +# Knowledge: (key, type, subject, description) +_ENTITIES = [ + ("coop", "organisation", CO_OP, "A neighbourhood energy co-op planning its first shared solar array."), + ("tenants", "organisation", "Mill Street Tenants Association", "Helps renters join without a roof of their own."), + ("library", "organisation", "Riverbend Library", "Offers its south-facing roof and a meeting room."), + ("school", "organisation", "Northside Primary School", "Its gym roof is shortlisted for a second array."), + ("credit", "organisation", "Riverbend Credit Union", "Discussing low-interest loans for member shares."), + ("grid", "organisation", "Regional grid operator", "Approves the connection and sets the credit rules."), + ("city", "organisation", "City sustainability office", "Runs the small-grants round."), + ("riverbend", "place", "Riverbend", "The neighbourhood the co-op serves."), + ("libroof", "place", "Library roof", "South-facing, with room for the first array."), + ("gymroof", "place", "Northside gym roof", "Large and flat; needs a structural check."), + ("millst", "place", "Mill Street", "Terraced housing, mostly rented."), + ("market", "place", "Market Hall", "Venue for the open evening."), + ("depot", "place", "Old tram depot", "The largest roof in the neighbourhood."), + ("chair", "person", "Board chair", "Chairs the monthly board meeting."), + ("treasurer", "person", "Treasurer", "Keeps the share register and the savings model."), + ("volco", "person", "Volunteer coordinator", "Runs training night and the survey rota."), + ("surveyor", "person", "Site surveyor", "Checks each roof's direction, shade and structure."), + ("liaison", "person", "Member liaison", "Answers member questions."), + ("openeve", "event", "Open evening", "The launch event for founding members."), + ("vote", "event", "Board vote on the first array", "Decides between the library and the depot."), + ("surveyday", "event", "Roof survey day", "Volunteers and the surveyor visit each shortlisted roof."), + ("deadline", "event", "Grant deadline", "The small-grants round closes at the end of the month."), + ("training", "event", "Volunteer training night", "Covers the open evening, surveys and member questions."), + ("launchday", "event", "Launch day", "The launch page goes live and the member drive starts."), + ("launchpage", "project", "Launch page", "The public page inviting neighbours to join."), + ("drive", "project", "Member drive", "Aims for 120 founding member households."), + ("survey", "project", "Rooftop survey", "Shortlists roofs for the first two arrays."), + ("grant", "project", "Grant application", "Asks the city to cover survey and legal costs."), + ("battery", "project", "Battery pilot", "A small storage trial at the library."), + ("digest", "project", "Weekly policy digest", "A local workflow that tracks rule changes."), + ("boardpack", "project", "Board pack", "Papers for the board vote."), + ("cs", "concept", "Community solar", "Many households share the output of one array."), + ("shared", "concept", "Shared ownership", "Members own the array together."), + ("credits", "concept", "Bill credits", "A share's output appears as a credit on the member's bill."), + ("subscription", "concept", "Subscription model", "Members pay monthly instead of buying a share."), + ("shares", "concept", "Member shares", "A one-off purchase that pays back over time."), + ("netmeter", "concept", "Net metering", "Exported power offsets power bought from the grid."), + ("vnm", "concept", "Virtual net metering", "Credits from one array spread across many bills."), + ("fit", "concept", "Feed-in tariff", "A set price paid for exported power."), + ("ppa", "concept", "Power purchase agreement", "A long-term contract to buy the array's output."), + ("interconnect", "concept", "Grid connection", "Permission and equipment to connect the array."), + ("capacity", "concept", "Capacity factor", "How much of its rated output an array delivers."), + ("payback", "concept", "Payback period", "How long a share takes to repay its cost."), + ("energycoop", "concept", "Energy cooperative", "A member-owned energy organisation."), + ("control", "concept", "Democratic member control", "Members decide together."), + ("onevote", "concept", "One member, one vote", "Every member has an equal say."), + ("renters", "concept", "Renter access", "People without a roof can still take part."), + ("equity", "concept", "Energy equity", "Fair access to clean, affordable power."), + ("carveout", "concept", "Low-income places", "Shares set aside for households on low incomes."), + ("storage", "concept", "Battery storage", "Keeps daytime power for the evening."), + ("peak", "concept", "Peak shaving", "Using storage to cut demand at the busiest times."), + ("inverter", "concept", "Inverter", "Turns the panels' direct current into mains power."), + ("suitability", "concept", "Roof suitability", "Direction, shade, strength and access."), + ("gridcap", "concept", "Grid capacity", "How much new generation the local network can take."), + ("benefit", "concept", "Community benefit fund", "Surplus income set aside for local projects."), + ("tariff", "concept", "Green tariff", "A supplier's renewable electricity offer."), + ("localfirst", "concept", "Local-first research", "Research runs on this computer and keeps its sources."), + ("evidence", "concept", "Visible evidence", "Every claim keeps a link to its source."), + ("f_south", "fact", "Library roof faces south", "Good for a first array."), + ("f_roofs", "fact", "Survey found 14 suitable roofs", "Out of 31 roofs checked."), + ("f_target", "fact", "Founding target: 120 households", "Set by the board in spring."), + ("f_instal", "fact", "Shares can be paid in instalments", "Agreed with the credit union."), + ("f_monthly", "fact", "Credits appear on the monthly bill", "Confirmed in the grid connection FAQ."), + ("f_noupfront", "fact", "Subscriptions need no upfront cost", "From the public sources."), + ("f_renters", "fact", "Renters can join without a roof", "From the public sources."), + ("f_months", "fact", "Connection approval takes months", "Start the application early."), + ("f_tuesday", "fact", "Board meets on the first Tuesday", "Monthly, at the library."), + ("f_thursday", "fact", "Open evening is on a Thursday", "At Market Hall, from 18:30."), + ("f_vote", "fact", "Each member gets one vote", "Whatever the size of their share."), + ("f_surplus", "fact", "Surplus goes to the community fund", "Agreed in the draft rules."), + ("f_gym", "fact", "Gym roof needs a structural check", "Booked for survey day."), + ("f_depot", "fact", "Depot roof is the largest site", "About three times the library roof."), + ("f_grant", "fact", "Grant covers survey costs", "And part of the legal fees."), + ("f_loans", "fact", "Credit union offers share loans", "Repaid over two years."), + ("f_rules", "fact", "Credit rules unchanged this month", "From the latest policy digest."), + ("f_faq", "fact", "Two new connection FAQ questions", "From the latest policy digest."), + ("m_cs", "media", "Community solar article", "Public source read during research."), + ("m_nm", "media", "Net metering article", "Public source read during research."), + ("m_coop", "media", "Cooperative article", "Public source read during research."), + ("m_guide", "media", "Co-op starter guide", "A public guide to setting up an energy co-op."), + ("m_faq", "media", "Grid connection FAQ", "The grid operator's public questions and answers."), + ("m_member", "media", "Member survey results", "What 86 neighbours said about joining."), + ("m_photos", "media", "Roof survey photos", "Photos from survey day, filed by roof."), + ("m_design", "media", "Launch page design", "The editable design in the Design panel."), + ("m_deck", "media", "Board deck: first array", "Slides for the board vote."), + ("m_flyer", "media", "Open evening flyer", "Printed for the library and Market Hall."), + ("m_d38", "media", "Policy digest: week 38", "Weekly digest from the local workflow."), + ("m_d39", "media", "Policy digest: week 39", "Weekly digest from the local workflow."), + ("m_d40", "media", "Policy digest: week 40", "Weekly digest from the local workflow."), + ("p_plain", "preference", "Prefers plain-language summaries", "Short sentences, no jargon."), + ("p_delivery", "preference", "Keep delivery off for drafts", "Results stay in the app until reviewed."), + ("p_metric", "preference", "Uses metric units", "Square metres and kilowatt-hours."), + ("p_cite", "preference", "Cite sources inline", "Every claim names where it came from."), + ("p_monday", "preference", "Digest on Monday mornings", "Ready before the week starts."), + ("p_tone", "preference", "Warm, neighbourly tone", "For anything members read."), + ("s_roof", "skill", "Roof suitability checks", "Direction, shade and access."), + ("s_writing", "skill", "Plain-language writing", "Turns rules into everyday words."), + ("s_model", "skill", "Savings modelling", "Share price, credits and payback."), + ("s_grant", "skill", "Grant writing", "Structure, budget and benefit."), + ("s_events", "skill", "Event planning", "Venues, rotas and flyers."), + ("k_local", "self_knowledge", f"Research runs on {LOCAL_MODELS[0]}", "On this computer, with sources kept."), + ("k_hosted", "self_knowledge", "Hosted models only when chosen", "A frontier model joins only when picked."), + ("k_drafts", "self_knowledge", "Drafts stay on this computer", "Nothing is sent without approval."), +] + +_RELATIONS = [ + ("coop", "riverbend", "based_in"), ("coop", "cs", "uses"), ("coop", "energycoop", "builds_on"), + ("tenants", "coop", "member_of"), ("library", "coop", "member_of"), ("school", "coop", "participates_in"), + ("credit", "coop", "participates_in"), ("coop", "grid", "uses"), ("city", "grant", "participates_in"), + ("libroof", "riverbend", "located_in"), ("gymroof", "riverbend", "located_in"), + ("millst", "riverbend", "located_in"), ("market", "riverbend", "located_in"), + ("depot", "riverbend", "located_in"), ("library", "libroof", "owns"), ("school", "gymroof", "owns"), + ("tenants", "millst", "based_in"), ("chair", "coop", "leads"), ("treasurer", "coop", "member_of"), + ("volco", "coop", "member_of"), ("surveyor", "survey", "works_on"), ("liaison", "drive", "works_on"), + ("treasurer", "s_model", "has_skill"), ("surveyor", "s_roof", "has_skill"), ("volco", "s_events", "has_skill"), + ("liaison", "s_writing", "has_skill"), ("chair", "vote", "leads"), ("treasurer", "grant", "works_on"), + ("volco", "training", "leads"), ("openeve", "market", "located_in"), ("openeve", "drive", "part_of"), + ("vote", "boardpack", "uses"), ("surveyday", "survey", "part_of"), ("deadline", "grant", "deadline_for"), + ("training", "volco", "created_by"), ("launchday", "launchpage", "scheduled_for"), + ("launchday", "drive", "part_of"), ("launchpage", "coop", "part_of"), ("launchpage", "m_design", "uses"), + ("launchpage", "drive", "part_of"), ("launchpage", "p_tone", "uses"), ("launchpage", "credits", "cites"), + ("launchpage", "shares", "cites"), ("drive", "coop", "part_of"), ("drive", "f_target", "uses"), + ("survey", "coop", "part_of"), ("survey", "suitability", "uses"), ("survey", "m_photos", "uses"), + ("grant", "coop", "part_of"), ("grant", "f_grant", "cites"), ("grant", "s_grant", "uses"), + ("battery", "libroof", "located_in"), ("battery", "storage", "uses"), ("battery", "peak", "uses"), + ("digest", "coop", "part_of"), ("digest", "k_local", "uses"), ("digest", "p_delivery", "uses"), + ("digest", "p_monday", "uses"), ("boardpack", "m_deck", "uses"), ("boardpack", "vote", "part_of"), + ("shared", "cs", "part_of"), ("credits", "cs", "part_of"), ("subscription", "cs", "part_of"), + ("shares", "cs", "part_of"), ("vnm", "netmeter", "extends"), ("credits", "vnm", "uses"), + ("netmeter", "credits", "builds_on"), ("fit", "netmeter", "contradicts"), ("ppa", "cs", "part_of"), + ("interconnect", "grid", "uses"), ("interconnect", "gridcap", "uses"), ("capacity", "payback", "builds_on"), + ("payback", "shares", "part_of"), ("energycoop", "control", "uses"), ("control", "onevote", "builds_on"), + ("renters", "cs", "part_of"), ("equity", "renters", "builds_on"), ("carveout", "equity", "part_of"), + ("storage", "peak", "uses"), ("inverter", "cs", "part_of"), ("suitability", "libroof", "cites"), + ("benefit", "energycoop", "part_of"), ("tariff", "cs", "contradicts"), ("localfirst", "evidence", "uses"), + ("localfirst", "k_local", "uses"), ("evidence", "p_cite", "builds_on"), ("shared", "energycoop", "builds_on"), + ("subscription", "shares", "contradicts"), ("credits", "f_monthly", "cites"), + ("f_south", "libroof", "part_of"), ("f_roofs", "survey", "part_of"), ("f_target", "drive", "part_of"), + ("f_instal", "shares", "part_of"), ("f_instal", "credit", "cites"), ("f_monthly", "m_faq", "cites"), + ("f_noupfront", "subscription", "part_of"), ("f_noupfront", "m_cs", "cites"), + ("f_renters", "renters", "part_of"), ("f_renters", "m_cs", "cites"), ("f_months", "interconnect", "part_of"), + ("f_months", "m_faq", "cites"), ("f_tuesday", "chair", "part_of"), ("f_thursday", "openeve", "part_of"), + ("f_vote", "onevote", "part_of"), ("f_vote", "m_coop", "cites"), ("f_surplus", "benefit", "part_of"), + ("f_gym", "gymroof", "part_of"), ("f_gym", "surveyday", "scheduled_for"), ("f_depot", "depot", "part_of"), + ("f_grant", "city", "cites"), ("f_loans", "credit", "part_of"), ("f_loans", "shares", "part_of"), + ("f_rules", "m_d40", "extracted_from"), ("f_rules", "credits", "part_of"), + ("f_faq", "m_d40", "extracted_from"), ("f_faq", "m_faq", "extends"), ("m_cs", "cs", "cites"), + ("m_nm", "netmeter", "cites"), ("m_coop", "energycoop", "cites"), ("m_guide", "energycoop", "cites"), + ("m_guide", "control", "cites"), ("m_faq", "grid", "created_by"), ("m_member", "drive", "part_of"), + ("m_member", "renters", "cites"), ("m_photos", "surveyday", "part_of"), ("m_design", "launchpage", "part_of"), + ("m_deck", "vote", "part_of"), ("m_flyer", "openeve", "part_of"), ("m_d38", "digest", "created_by"), + ("m_d39", "digest", "created_by"), ("m_d40", "digest", "created_by"), ("m_d39", "m_d38", "extends"), + ("m_d40", "m_d39", "extends"), ("m_d38", "f_months", "cites"), ("p_plain", "s_writing", "uses"), + ("p_cite", "evidence", "uses"), ("p_metric", "s_model", "uses"), ("p_tone", "m_flyer", "uses"), + ("p_plain", "launchpage", "uses"), ("s_model", "payback", "uses"), ("s_model", "credits", "uses"), + ("s_roof", "suitability", "uses"), ("s_grant", "grant", "uses"), ("s_events", "openeve", "uses"), + ("s_writing", "m_flyer", "uses"), ("k_local", "localfirst", "part_of"), ("k_hosted", "launchpage", "uses"), + ("k_drafts", "p_delivery", "uses"), ("depot", "vote", "participates_in"), ("libroof", "vote", "participates_in"), + ("gymroof", "survey", "part_of"), ("depot", "survey", "part_of"), ("libroof", "survey", "part_of"), + ("millst", "renters", "uses"), ("tenants", "renters", "interested_in"), ("tenants", "carveout", "interested_in"), + ("school", "battery", "interested_in"), ("library", "battery", "participates_in"), + ("grid", "interconnect", "owns"), ("grid", "netmeter", "uses"), ("city", "equity", "interested_in"), + ("coop", "benefit", "owns"), ("coop", "control", "uses"), ("coop", "shares", "uses"), + ("coop", "subscription", "uses"), ("coop", "localfirst", "uses"), ("treasurer", "payback", "studies"), + ("chair", "m_deck", "authored"), ("liaison", "m_member", "authored"), ("volco", "m_flyer", "authored"), +] + + +def _entity_id(key: str) -> str: + # Fixed ids give the graph the same layout on every capture; this seed + # spreads the co-op's labelled neighbours without overlapping labels. + return hashlib.sha256(f"v28-{key}".encode()).hexdigest()[:12] + + +def _seed_knowledge(anchor: datetime) -> None: + from row_bot import knowledge_graph as kg + + previous = kg._skip_reindex + kg._skip_reindex = True + try: + for key, entity_type, subject, description in _ENTITIES: + kg.save_entity(entity_type, subject, description, tags="riverbend,demo", + properties={"provenance": "landing demo fixture"}, source="landing-demo", + entity_id=_entity_id(key)) + for source, target, relation in _RELATIONS: + # Fixed relation ids too: the graph reads its edges in id order, and + # that order steers the layout. + kg.add_relation(_entity_id(source), _entity_id(target), relation, confidence=0.95, + properties={"provenance": "landing demo fixture"}, source="landing-demo", + relation_id=_entity_id(f"{source}>{target}")) + finally: + kg._skip_reindex = previous + moment = (anchor - timedelta(minutes=26)).isoformat() + with sqlite3.connect(Path(os.environ["ROW_BOT_DATA_DIR"]) / "memory.db") as conn: + conn.execute("UPDATE entities SET created_at = ?, updated_at = ?", (moment, moment)) + conn.execute("UPDATE relations SET created_at = ?, updated_at = ?", (moment, moment)) + + +def _launch_page_html() -> str: + """The fictional co-op's launch page, as the Design panel stores it.""" + ink, cream, sun, muted = "#1F2A1C", "#FFF8EC", "#F59E0B", "#5B6656" + eyebrow = "margin:0 0 14px;font-size:20px;font-weight:700;letter-spacing:4px;color:#B45309" + steps = "".join( + "
" + f"

{number}

" + f"

{title}

" + f"

{detail}

" + for number, title, detail, color in ( + ("01", "Join for a share", "Subscribe monthly or buy a share outright. Renters welcome.", "#D97706"), + ("02", "We build together", "One array on the library roof, owned by its members.", "#16A34A"), + ("03", "Credits on your bill", "Your share's sunshine appears on every monthly bill.", "#0E7490"), + ) + ) + stats = "".join( + f"

{value}

" + f"

{label}

" + for value, label in (("120", "founding households"), ("14", "suitable roofs"), ("1", "vote per member")) + ) + benefits = "".join( + f"
" + f"

{title}

" + f"

{detail}

" + for title, detail, bg in ( + ("Lower bills", "Credits from your share arrive on every bill, all year.", "#FFEFC7"), + ("A local fund", "Surplus goes to projects the members choose together.", "#E3F1DE"), + ("A real say", "Every member votes on what the co-op builds next.", "#DCEEF3"), + ("No roof needed", "Renters and flat owners join on the same terms.", "#F7E3D6"), + ) + ) + questions = "".join( + f"
" + f"

{q}

" + f"

{a}

" + for q, a in ( + ("Do I need my own roof?", "No. Your share is part of the array on the library roof."), + ("Can I pay over time?", "Yes. Shares can be paid in instalments with the credit union."), + ("What happens to the surplus?", "It goes to the community fund, and members decide how it is spent."), + ) + ) + return ( + f"
" + "
" + "" + f"

COMMUNITY-OWNED ENERGY

" + "

" + "Power your street, together.

" + "

" + "Riverbend Community Solar puts one shared array on the library roof. Every member owns a piece, " + "and every share earns credits on the bill.

" + f"
Become a founding member" + "Come to the open evening
" + "
" + "
" + "
" + f"

HOW SHARES WORK

" + "

" + "Three steps to clean, local power

" + f"
{steps}
" + f"
" + "

Owned by neighbours, " + "run by members

One member, one " + "vote. Surplus goes to the community fund for local projects.

" + f"
{stats}
" + "
" + f"

WHAT MEMBERS GET BACK

" + "

More than cheaper power

" + f"
{benefits}
" + "
" + f"

QUESTIONS

Asked at every street meeting

" + f"
{questions}
" + f"
" + "

Open evening, Thursday 18:30

" + "

Market Hall, Riverbend. Bring your questions and your " + f"neighbours.

Save my place
" + f"

☀ Riverbend Community Solar · a member-owned co-op

" + "

Library roof array · Market Hall open evenings

" + "
" + ) + + +def _seed_ship_goal(anchor: datetime) -> None: + """The Ship conversation works toward a goal, shown in its details beside the approval.""" + from row_bot.goals import start_goal + + goal = start_goal(SHIP_THREAD, "Share the reviewed launch page with the co-op board", max_turns=6) + progress = ["Checked the page against the research", "Exported riverbend-launch-page.pdf"] + started = (anchor - timedelta(minutes=4)).isoformat() + with _tasks_db() as conn: + conn.execute( + "UPDATE thread_goals SET turns_used = ?, last_progress = ?, evidence_json = ?, blockers_json = ?, " + "last_reason = ?, created_at = ?, updated_at = ?, window_started_at = ? WHERE id = ?", + (2, progress[-1], json.dumps(progress), json.dumps(["Waiting for approval to send the email"]), + "The email waits for your approval.", started, started, started, goal["id"]), + ) + + +def _seed_design() -> None: + from row_bot.conversation_resources import bind, list_bindings + from row_bot.designer.state import BrandConfig, DesignerPage, DesignerProject, ProjectBrief + from row_bot.designer.storage import save_project + + save_project(DesignerProject( + id=DESIGN_ID, + name="Riverbend launch page", + mode="landing", + aspect_ratio="landing", + pages=[DesignerPage(title="Home", route_id="home", kind="screen", html=_launch_page_html(), + notes="Hero, how shares work, ownership, member benefits and the open evening.")], + brand=BrandConfig(primary_color="#1F2A1C", accent_color="#F59E0B"), + brief=ProjectBrief(output_type="Landing page", audience="Riverbend neighbours", + tone="Warm and neighbourly", length="One page"), + thread_id=CREATE_THREAD, + )) + snapshot = list_bindings(CREATE_THREAD) + bind(CREATE_THREAD, "artifact", DESIGN_ID, expected_revision=snapshot.revision, role="primary") + + +def _seed_model_catalog() -> None: + """The picker's local models, read from the display-only runtime, as Quick Choices.""" + from row_bot.providers.model_catalog_cache import refresh_model_catalog_cache + from row_bot.providers.selection import add_quick_choice_for_model + + refresh_model_catalog_cache(reason="landing_demo", force=True, provider_id="ollama") + for model_id in LOCAL_MODELS: + add_quick_choice_for_model(model_id, provider_id="ollama") + + +def _bind_profile(data_dir: Path, ollama_host: str = "") -> None: + os.environ["ROW_BOT_DATA_DIR"] = str(data_dir) + if ollama_host: + os.environ["OLLAMA_HOST"] = ollama_host + os.environ.setdefault("ROW_BOT_DOCS_CAPTURE", "1") + src = str(ROOT / "src") + if src not in sys.path: + sys.path.insert(0, src) + + +def seed_profile(data_dir: Path, ollama_host: str, anchor: datetime) -> None: + """Seed the fictional co-op into the capture profile (in this process).""" + _bind_profile(data_dir, ollama_host) + from scripts.docs import seed_real_app_demo_data as demo + from row_bot.docs_capture import scan_demo_data_safety + + demo._seed_app_config(data_dir, first_run=False) + demo._write_json(data_dir / "model_settings.json", {"model": LOCAL_MODEL}) + _seed_conversations(anchor) + _seed_workflows(anchor) + _seed_knowledge(anchor) + _seed_design() + _seed_ship_goal(anchor) + _seed_model_catalog() + errors = scan_demo_data_safety(data_dir) + if errors: + raise SystemExit("\n".join(errors)) + + +def add_ship_approval(data_dir: Path, anchor: datetime) -> None: + """The email waits for Approve or Deny in its conversation.""" + _bind_profile(data_dir) + from row_bot.tasks import create_approval_request + + interrupt = { + "tool": "send_gmail_message", + "label": "Send email", + "description": "Email riverbend-launch-page.pdf to riverbend-board@example.com", + "args": { + "to": ["riverbend-board@example.com"], + "subject": "Launch page for review", + "attachments": ["riverbend-launch-page.pdf"], + }, + "tool_call_id": "s2", + "risk_class": "medium", + "scope": "One email to riverbend-board@example.com, with the PDF attached.", + } + _token, approval_id = create_approval_request( + "landing-ship-pass", "", "conversation", interrupt["description"], + resume_kind="conversation", source_thread_id=SHIP_THREAD, parent_thread_id=SHIP_THREAD, + approval_payload_json={ + "interrupt": interrupt, + "interrupt_ids": [], + "pass_id": "landing-ship-pass", + "model_selection": {"provider_id": "ollama", "model_ref": LOCAL_MODEL}, + }, + ) + with _tasks_db() as conn: + conn.execute("UPDATE approval_requests SET id = ?, requested_at = ? WHERE id = ?", + (SHIP_APPROVAL_ID, (anchor - timedelta(minutes=1)).isoformat(), approval_id)) + + +# The app and the browser -------------------------------------------------------- + +class _LandingOllamaHandler(_DemoOllamaHandler): + """The docs capture's display-only local runtime, listing the landing footage's models.""" + + def do_GET(self) -> None: # noqa: N802 - http.server naming + if self.path.startswith("/api/tags"): + self._reply(200, {"models": [self._model(name) for name in LOCAL_MODELS]}) + else: + super().do_GET() + + def do_POST(self) -> None: # noqa: N802 - http.server naming + length = int(self.headers.get("Content-Length") or 0) + try: + request = json.loads(self.rfile.read(length) or b"{}") + except ValueError: + request = {} + name = str(request.get("model") or request.get("name") or "") + if self.path.startswith("/api/show") and name in LOCAL_MODELS: + details = self._model(name)["details"] + family = details["family"] + self._reply(200, { + "details": details, + "model_info": {"general.architecture": family, f"{family}.context_length": 32768}, + "capabilities": ["completion", "tools"], + }) + else: + self._reply(503, {"error": "the landing capture never runs a model"}) + + +def _start_demo_ollama(stack: ExitStack) -> str: + """Serve the display-only local runtime on loopback for the app's lifetime.""" + server = ThreadingHTTPServer(("127.0.0.1", 0), _LandingOllamaHandler) + threading.Thread(target=server.serve_forever, name="landing-demo-ollama", daemon=True).start() + stack.callback(server.server_close) + stack.callback(server.shutdown) + return f"http://127.0.0.1:{server.server_address[1]}" + + +class App: + """The real app on the isolated profile; stopped when the stack closes.""" + + def __init__(self, stack: ExitStack, data_dir: Path, ollama_host: str) -> None: + self.port = _free_port() + self.proc, secret = _launch_app(self.port, data_dir, stack, ollama_host=ollama_host) + stack.callback(self.stop) + _wait_ping(self.port, self.proc, 120, launcher_secret=secret) + self.base = f"http://127.0.0.1:{self.port}/app-v2" + + def stop(self) -> None: + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=15) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=15) + + +def _helper(*args: str) -> None: + subprocess.run([sys.executable, str(Path(__file__).resolve()), *args], cwd=str(ROOT), check=True) + + +_HOSTED_CHOICE = { + "provider_id": "codex", + "model_ref": HOSTED_MODEL, + "label": "GPT-5.6 Sol - ChatGPT / Codex", + "available": True, + "unavailable_reason": None, + "billing": "subscription", +} + + +def _hosted_model_is_display_only(page) -> None: + """Show GPT-5.6 Sol as a connected choice; nothing is connected or called.""" + + def ready(workspace: dict) -> None: + selection = (workspace.get("controls") or {}).get("model_selection") or {} + if selection.get("provider_id") == "codex": + workspace["model_status"] = {"state": "ready", "reason": "", "fix": None, + "local": False, "sees_images": True} + for action in workspace.get("actions") or []: + action.update(ready=True, code=None) + + def handshake(route) -> None: + response = route.fetch() + body = response.json() + body["models"] = [*body.get("models", []), _HOSTED_CHOICE] + route.fulfill(response=response, json=body) + + def opened(route) -> None: + response = route.fetch() + body = response.json() + ready(body.get("workspace") or {}) + route.fulfill(response=response, json=body) + + def workspace(route) -> None: + response = route.fetch() + body = response.json() + ready(body) + route.fulfill(response=response, json=body) + + page.route("**/api/v1/handshake", handshake) + page.route("**/api/v1/conversations/*/open", opened) + page.route("**/api/v1/conversations/*/workspace", workspace) + + +def _approval_is_answered_here(page) -> None: + """Answer Approve in the harness: the app shows its submitted state and nothing is sent.""" + + def answer(route) -> None: + command = json.loads(route.request.post_data or "{}") + if command.get("type") != "approval.resolve": + route.abort() + return + route.fulfill(status=200, json={"command_id": command.get("command_id", ""), "status": "accepted", + "approval_id": SHIP_APPROVAL_ID}) + + page.route("**/api/v1/approvals/*/commands", answer) + + +_POINTER = """ +(() => { + if (window.top !== window) return; + const install = () => { + if (document.getElementById('landing-pointer')) return; + const pointer = document.createElement('div'); + pointer.id = 'landing-pointer'; + pointer.innerHTML = ''; + pointer.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;opacity:0;' + + 'transition:opacity .25s ease;filter:drop-shadow(0 2px 3px rgba(0,0,0,.45));will-change:transform'; + const ring = pointer.querySelector('span'); + ring.style.cssText = 'position:absolute;left:-12px;top:-12px;width:28px;height:28px;border-radius:50%;' + + 'border:2px solid rgba(255,255,255,.85);opacity:0;transform:scale(.4)'; + document.documentElement.appendChild(pointer); + window.__pointer = (x, y, show) => { + pointer.style.transform = `translate(${x - 2}px, ${y - 2}px)`; + if (show != null) pointer.style.opacity = show ? '1' : '0'; + }; + window.__press = () => ring.animate( + [{opacity: .9, transform: 'scale(.4)'}, {opacity: 0, transform: 'scale(1.25)'}], + {duration: 420, easing: 'ease-out'}); + }; + if (document.documentElement) install(); + document.addEventListener('DOMContentLoaded', install); +})(); +""" + + +def _ease(t: float) -> float: + return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2 + + +class Director: + """Human-paced pointer moves, clicks and scrolls, with the drawn pointer.""" + + def __init__(self, page, start: tuple[float, float] = (WIDTH * 0.72, HEIGHT * 0.62)) -> None: + self.page = page + self.x, self.y = start + + def place(self, x: float, y: float, show: bool | None = None) -> None: + self.x, self.y = x, y + self.page.mouse.move(x, y) + self.page.evaluate("([x, y, show]) => window.__pointer && window.__pointer(x, y, show)", + [x, y, show]) + + def show(self) -> None: + self.place(self.x, self.y, True) + + def hide(self) -> None: + self.place(self.x, self.y, False) + + def move(self, x: float, y: float, ms: int = 800) -> None: + start = time.monotonic() + x0, y0 = self.x, self.y + while True: + t = min(1.0, (time.monotonic() - start) * 1000 / ms) + k = _ease(t) + self.place(x0 + (x - x0) * k, y0 + (y - y0) * k) + if t >= 1: + return + self.page.wait_for_timeout(12) + + def to(self, locator, ms: int = 800, dx: float = 0, dy: float = 0) -> None: + box = locator.bounding_box() + if not box: + raise RuntimeError(f"not on screen: {locator}") + self.move(box["x"] + box["width"] / 2 + dx, box["y"] + box["height"] / 2 + dy, ms) + + def click(self, locator=None, ms: int = 800, settle: int = 120) -> None: + if locator is not None: + self.to(locator, ms) + self.page.wait_for_timeout(settle) + self.page.evaluate("() => window.__press && window.__press()") + self.page.mouse.down() + self.page.wait_for_timeout(70) + self.page.mouse.up() + + def scroll(self, dy: float, ms: int = 600) -> None: + steps = max(1, ms // 16) + for _ in range(steps): + self.page.mouse.wheel(0, dy / steps) + self.page.wait_for_timeout(16) + + def wait(self, ms: int) -> None: + self.page.wait_for_timeout(ms) + + +class Recorder: + """The page's screencast, saved frame by frame with its timestamps.""" + + def __init__(self, context, page, folder: Path) -> None: + self.folder = folder + self.frames: list[tuple[float, Path]] = [] + self.marks: dict[str, float] = {} + self.cdp = context.new_cdp_session(page) + self.cdp.on("Page.screencastFrame", self._frame) + self.recording = False + + def _frame(self, params: dict) -> None: + if self.recording: + path = self.folder / f"{len(self.frames):05d}.png" + path.write_bytes(base64.b64decode(params["data"])) + self.frames.append((float(params["metadata"]["timestamp"]), path)) + try: + self.cdp.send("Page.screencastFrameAck", {"sessionId": params["sessionId"]}) + except Exception: # noqa: BLE001 - the page may already be closing + pass + + def start(self, page) -> None: + self.recording = True + self.cdp.send("Page.startScreencast", {"format": "png", "everyNthFrame": 1}) + deadline = time.monotonic() + 5 + while not self.frames and time.monotonic() < deadline: + page.wait_for_timeout(20) + # Repaint once so the first frame is current. + page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))") + + def mark(self, name: str) -> None: + self.marks[name] = time.time() + + def stop(self, page) -> None: + page.wait_for_timeout(200) + self.cdp.send("Page.stopScreencast") + self.recording = False + + def sample(self, start: float, end: float) -> list[Path]: + """Frames at 30 fps between two marks, each the newest shown at its moment.""" + frames = sorted(self.frames) + picked: list[Path] = [] + index = 0 + count = round((end - start) * FPS) + for n in range(count): + moment = start + n / FPS + while index + 1 < len(frames) and frames[index + 1][0] <= moment: + index += 1 + picked.append(frames[index][1]) + return picked + + +def _new_context(browser, *, scale: int, clock: Callable[[], datetime], pointer: bool): + context = browser.new_context( + viewport={"width": WIDTH, "height": HEIGHT}, + device_scale_factor=scale, + color_scheme="dark", + reduced_motion="no-preference", + locale="en-GB", + timezone_id="Europe/London", + ) + # The approval counts as announced (no floating notice over the scene), and + # the sidebar's agents row is folded so the conversation list ends tidily. + context.add_init_script( + "try { localStorage.setItem('row-bot.approvals-announced.v1', '[\"%s\"]');" + " localStorage.setItem('row-bot.sidebar-agents.v1', 'collapsed') } catch (e) {}" + % SHIP_APPROVAL_ID + ) + if pointer: + context.add_init_script(_POINTER) + page = context.new_page() + page.clock.set_system_time(clock()) + _hosted_model_is_display_only(page) + _approval_is_answered_here(page) + return context, page + + +def _open(page, base: str, route: str, text: str) -> None: + page.goto(base + route, wait_until="load", timeout=60_000) + page.get_by_text(text, exact=False).first.wait_for(timeout=30_000) + page.wait_for_timeout(800) + + +def _close_details(page) -> None: + """Close the conversation's details rail (its goal card) if it is open.""" + toggle = page.locator('button[aria-label="Conversation details"]').first + if toggle.get_attribute("aria-pressed") == "true": + toggle.click() + page.wait_for_timeout(400) + _blur(page) + + +def _blur(page) -> None: + page.evaluate("() => document.activeElement && document.activeElement.blur && document.activeElement.blur()") + + +def _park(page) -> None: + page.mouse.move(WIDTH - 2, HEIGHT - 2) + + +# Scenes --------------------------------------------------------------------------- + +def _graph_hub(page, scale: int) -> tuple[float, float]: + """Where the co-op's memory sits: the largest organisation-coloured node.""" + from PIL import Image + + image = Image.open(io.BytesIO(page.screenshot())).convert("RGB") + width, height = image.size + pixels = image.load() + target = (213, 81, 129) + left = int(310 * scale) + mask = { + (x, y) + for y in range(int(60 * scale), height, 1) + for x in range(left, int(1250 * scale)) + if sum(abs(a - b) for a, b in zip(pixels[x, y], target)) < 24 + } + best: list[tuple[int, int]] = [] + while mask: + seed = mask.pop() + blob, queue = [seed], deque([seed]) + while queue: + x, y = queue.popleft() + for neighbour in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if neighbour in mask: + mask.remove(neighbour) + blob.append(neighbour) + queue.append(neighbour) + if len(blob) > len(best): + best = blob + if not best: + raise RuntimeError("the co-op's memory is not on the graph") + return (sum(x for x, _ in best) / len(best) / scale, sum(y for _, y in best) / len(best) / scale) + + +def _setup_research(page, base: str) -> None: + _open(page, base, f"/conversations/{RESEARCH_THREAD}", "Used 8 tools") + _close_details(page) + + +def _settled_hub(page, base: str, scale: int) -> tuple[float, float]: + page.goto(base + "/?tab=knowledge", wait_until="load", timeout=60_000) + page.locator('[aria-label^="Interactive knowledge graph"]').wait_for(timeout=30_000) + page.wait_for_timeout(7000) + return _graph_hub(page, scale) + + +def _setup_create(page, base: str) -> None: + _open(page, base, f"/conversations/{CREATE_THREAD}", "Launch page direction") + preview = page.locator('iframe[title="Page preview: Home"]') + page.wait_for_timeout(1000) + if not preview.is_visible(): + # The conversation's details list its design; opening it docks the panel. + details = page.locator('button[aria-label="Conversation details"]').first + if details.get_attribute("aria-pressed") != "true": + details.click() + page.locator('button[title="Open Riverbend launch page"]').first.click() + preview.wait_for(timeout=30_000) + page.locator('button[aria-label="Toggle navigation"]').first.click() + page.wait_for_timeout(1500) + # Widen the panel so the page reads at a glance beside the conversation. + handle = page.locator('[role="separator"][aria-label="Resize side panel"]').first + box = handle.bounding_box() + page.mouse.move(box["x"] + 1, box["y"] + 400) + page.mouse.down() + page.mouse.move(box["x"] - 150, box["y"] + 400, steps=8) + page.mouse.move(620, box["y"] + 400, steps=8) + page.mouse.up() + page.wait_for_timeout(1500) + _blur(page) + + +def _setup_automate(page, base: str) -> None: + _open(page, base, "/?tab=workflows", "Weekly policy digest") + page.wait_for_timeout(600) + + +def _workflow_menu(page, d: Director | None, item: str) -> None: + row = page.locator('[data-home-tab="workflows"]').get_by_text("Weekly policy digest").first + more = page.locator('button[aria-label="More actions for Weekly policy digest"]') + if d is None: + row.hover() + more.click() + page.get_by_role("menuitem", name=item).click() + return + d.to(row, ms=600, dx=420) + d.click(more, ms=450) + d.wait(300) + d.click(page.get_by_role("menuitem", name=item), ms=450) + + +def _run_history(page, base: str) -> None: + """The workflow's run drawer, as the Automate still shows it.""" + _setup_automate(page, base) + _workflow_menu(page, None, "Run history") + page.get_by_text("History").first.wait_for(timeout=15_000) + page.wait_for_timeout(900) + _blur(page) + + +def _setup_ship(page, base: str) -> None: + # The details beside it show the goal; they also keep the approval clear of + # Buddy, who stands over the right of the landing page's Ship scene. + _open(page, base, f"/conversations/{SHIP_THREAD}", "Send an email?") + details = page.locator('button[aria-label="Conversation details"]').first + if details.get_attribute("aria-pressed") != "true": + details.click() + page.get_by_text("Share the reviewed launch page").first.wait_for(timeout=15_000) + page.wait_for_timeout(600) + _blur(page) + + +SETUP = {"research": _setup_research, "create": _setup_create, "automate": _setup_automate, "ship": _setup_ship} + + +def _still(browser, base: str, scene: str, clock: Callable[[], datetime], out: Path) -> None: + from PIL import Image + + context, page = _new_context(browser, scale=2, clock=clock, pointer=False) + try: + if scene == "research": + x, y = _settled_hub(page, base, 2) + page.mouse.move(x - 40, y + 30) + page.mouse.move(x, y, steps=6) + page.wait_for_timeout(1200) + elif scene == "automate": + _run_history(page, base) + _park(page) + else: + SETUP[scene](page, base) + _park(page) + page.wait_for_timeout(1500) + raw = page.screenshot(animations="disabled") + finally: + context.close() + image = Image.open(io.BytesIO(raw)).convert("RGB").resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) + image.save(out, "WEBP", quality=86, method=6) + + +def _record_research(page, base: str, d: Director, rec: Recorder) -> list[tuple[str, str]]: + # Find the settled co-op memory first; reloading forgets the layout, so + # the recorded graph settles again from the start. + x, y = _settled_hub(page, base, 1) + _setup_research(page, base) + _park(page) + page.wait_for_timeout(1000) + trace = page.get_by_text("Used 8 tools").first + d.place(980, 560) + rec.start(page) + rec.mark("a0") + d.wait(400) + d.show() + d.click(trace, ms=900) + d.wait(400) + d.move(d.x + 140, d.y + 60, 800) + d.wait(700) + rec.mark("a1") + page.locator('a[aria-label="Home"]').first.click() + page.get_by_role("tab", name="Knowledge").click() + rec.mark("b0") + d.place(1190, 700) + d.wait(3500) + d.move(x + 60, y + 70, 700) + d.move(x, y, 450) + d.wait(1250) + rec.mark("b1") + rec.stop(page) + return [("a0", "a1"), ("b0", "b1")] + + +def _record_create(page, base: str, d: Director, rec: Recorder) -> list[tuple[str, str]]: + pill = page.locator('button[aria-label="Model"]').first + d.place(470, 560) + rec.start(page) + rec.mark("s") + d.wait(700) + d.show() + d.click(pill, ms=900) + d.wait(500) + d.to(page.locator(".model-picker").get_by_text("GPT-5.6 Sol").first, ms=600) + d.wait(1000) + page.keyboard.press("Escape") + d.wait(400) + d.click(page.get_by_role("radio", name="Edit"), ms=900) + d.wait(900) + # The preview is the page scaled into the panel: map the headline into it. + box = page.locator('iframe[title="Page preview: Home"]').bounding_box() + left, top, width, height, page_width = page.frame_locator('iframe[title="Page preview: Home"]').locator( + "h1").first.evaluate("e => { const r = e.getBoundingClientRect(); " + "return [r.x, r.y, r.width, r.height, document.documentElement.clientWidth]; }") + scale = box["width"] / page_width + d.move(box["x"] + (left + width * 0.42) * scale, box["y"] + (top + height * 0.28) * scale, 800) + d.wait(300) + d.click() + d.wait(1500) + rec.mark("e") + rec.stop(page) + return [("s", "e")] + + +def _record_automate(page, base: str, d: Director, rec: Recorder) -> list[tuple[str, str]]: + # Everything that matters stays above and left of Buddy, who stands over + # the lower right of the landing page's Automate scene. Three takes: the + # delivery defaults, the workflow's local model saved, and its run history. + d.place(760, 470) + rec.start(page) + rec.mark("a0") + d.wait(400) + d.show() + d.click(page.locator('button[aria-label="Delivery defaults"]'), ms=700) + page.get_by_text("No external channels are set up.").first.wait_for(timeout=15_000) + d.move(1085, 232, 500) + d.wait(1250) + page.keyboard.press("Escape") + d.wait(200) + rec.mark("a1") + _workflow_menu(page, d, "Workflow settings") + page.get_by_text("Readiness is checked when it runs").first.wait_for(timeout=15_000) + rec.mark("b0") + d.wait(150) + d.to(page.get_by_text(f"{LOCAL_MODELS[0]} - Ollama Local").first, ms=650) + d.wait(1000) + d.click(page.get_by_role("button", name="Save settings"), ms=600) + page.get_by_text("4 workflows").first.wait_for(timeout=15_000) + d.wait(250) + rec.mark("b1") + _workflow_menu(page, d, "Run history") + page.get_by_text("History").first.wait_for(timeout=15_000) + _blur(page) # The drawer focuses its first button, whose tooltip would cover the history. + rec.mark("c0") + d.wait(200) + d.move(1075, 400, 650) + d.wait(1650) + rec.mark("c1") + rec.stop(page) + return [("a0", "a1"), ("b0", "b1"), ("c0", "c1")] + + +def _record_ship(page, base: str, d: Director, rec: Recorder) -> list[tuple[str, str]]: + approve = page.locator('aside.approval-card button[aria-label="Approve"]').first + # The landing page turns Buddy to "success" 2.25 s into this clip + # (SHIP_APPROVAL_AT in docs/landing-story.js), so Approve lands there. + d.place(830, 690) + rec.start(page) + rec.mark("s") + d.wait(1100) + d.show() + d.to(approve, ms=950) + d.wait(120) + rec.mark("click") + d.click(settle=0) + d.wait(560) + rec.mark("e") + rec.stop(page) + return [("s", "e")] + + +RECORD = {"research": _record_research, "create": _record_create, "automate": _record_automate, + "ship": _record_ship} + + +def _clip_frames(rec: Recorder, cuts: list[tuple[str, str]], folder: Path) -> tuple[Path, int]: + """The edited clip as 30 fps PNG frames: segments joined by short dissolves.""" + from PIL import Image + + segments = [rec.sample(rec.marks[a], rec.marks[b]) for a, b in cuts] + frames: list[Any] = list(segments[0]) + for segment in segments[1:]: + tail, head = frames[-DISSOLVE_FRAMES:], segment[:DISSOLVE_FRAMES] + blends = [ + Image.blend(Image.open(a).convert("RGB"), Image.open(b).convert("RGB"), (i + 1) / (DISSOLVE_FRAMES + 1)) + for i, (a, b) in enumerate(zip(tail, head)) + ] + frames = frames[:-DISSOLVE_FRAMES] + blends + list(segment[DISSOLVE_FRAMES:]) + for index, frame in enumerate(frames): + target = folder / f"{index:05d}.png" + if isinstance(frame, Path): + image = Image.open(frame).convert("RGB") + else: + image = frame + if image.size != (WIDTH, HEIGHT): + image = image.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) + image.save(target) + return folder, len(frames) + + +def _ffmpeg() -> str: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + + +def _encode(frames: Path, name: str, out_dir: Path) -> None: + ffmpeg = _ffmpeg() + source = ["-framerate", str(FPS), "-i", str(frames / "%05d.png")] + webm = out_dir / f"{name}.webm" + mp4 = out_dir / f"{name}.mp4" + log = SCRATCH / f"{name}-vp9" + common = [ffmpeg, "-v", "error", "-y", *source, "-an", "-pix_fmt", "yuv420p"] + vp9 = ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "36", "-row-mt", "1", "-tile-columns", "2", + "-deadline", "good", "-cpu-used", "1", "-g", "150", "-passlogfile", str(log)] + subprocess.run([*common, *vp9, "-pass", "1", "-f", "webm", os.devnull], check=True) + subprocess.run([*common, *vp9, "-pass", "2", str(webm)], check=True) + subprocess.run([*common, "-c:v", "libx264", "-preset", "veryslow", "-crf", "25", "-profile:v", "high", + "-tune", "stillimage", "-g", "150", "-movflags", "+faststart", str(mp4)], check=True) + for leftover in SCRATCH.glob(f"{name}-vp9*.log"): + leftover.unlink() + + +def capture(scenes: list[str]) -> dict[str, Any]: + from playwright.sync_api import sync_playwright + + stills_dir = MEDIA / "screenshots" + clips_dir = MEDIA / "clips" + anchor = _anchor() + started = time.time() + + def clock() -> datetime: + return anchor + timedelta(seconds=time.time() - started) + + timings: dict[str, Any] = {} + SCRATCH.mkdir(parents=True, exist_ok=True) + with ExitStack() as stack: + profile = stack.enter_context(tempfile.TemporaryDirectory(prefix="profile-", dir=SCRATCH, + ignore_cleanup_errors=True)) + data_dir = Path(profile).resolve() + ollama = _start_demo_ollama(stack) + _helper("--seed", str(data_dir), "--ollama-host", ollama, "--anchor", anchor.isoformat()) + pw = stack.enter_context(sync_playwright()) + browser = pw.chromium.launch(channel="msedge", headless=True) + stack.callback(browser.close) + groups = [[s for s in scenes if s != "ship"], [s for s in scenes if s == "ship"]] + for group in groups: + if not group: + continue + with ExitStack() as run: + if group == ["ship"]: + _helper("--add-approval", str(data_dir), "--anchor", anchor.isoformat()) + app = App(run, data_dir, ollama) + for scene in group: + print(f"{scene}: still", flush=True) + _still(browser, app.base, scene, clock, stills_dir / f"{scene}.webp") + print(f"{scene}: clip", flush=True) + # The screencast and the edited frames are scratch; only the encodes are kept. + raw = Path(run.enter_context(tempfile.TemporaryDirectory(prefix=f"{scene}-raw-", dir=SCRATCH))) + edited = Path(run.enter_context(tempfile.TemporaryDirectory(prefix=f"{scene}-", dir=SCRATCH))) + context, page = _new_context(browser, scale=1, clock=clock, pointer=True) + try: + SETUP[scene](page, app.base) + _park(page) + page.wait_for_timeout(1200) + rec = Recorder(context, page, raw) + cuts = RECORD[scene](page, app.base, Director(page), rec) + finally: + context.close() + frames, count = _clip_frames(rec, cuts, edited) + _encode(frames, scene, clips_dir) + first = rec.marks[cuts[0][0]] + timings[scene] = { + "segments_seconds": [[round(rec.marks[a] - first, 3), round(rec.marks[b] - first, 3)] + for a, b in cuts], + "frames": count, + "raw_frames": len(rec.frames), + } + if "click" in rec.marks: + timings[scene]["click_seconds"] = round(rec.marks["click"] - first, 3) + saved = SCRATCH / "timings.json" + merged = json.loads(saved.read_text(encoding="utf-8")) if saved.exists() else {} + saved.write_text(json.dumps({**merged, scene: timings[scene]}, indent=2), encoding="utf-8") + return timings + + +STORY_ID = "react-workbench-v1" +PRODUCTION = { + "app": "Row-Bot 5.0.0, the real app process and its React client at /app-v2/", + "method": ( + "Scripted Playwright capture (scripts/docs/capture_landing_media.py) in Microsoft Edge at 1440x810 CSS px, " + "dark appearance, on an isolated temporary profile seeded with fictional demo data (a neighbourhood solar " + "co-op); no real user data, accounts, names or paths" + ), + "models": ( + "No model ran. The local runtime is the docs capture's display-only stand-in on loopback: it lists " + f"{LOCAL_MODELS[0]}, the local model the owner runs, and runs nothing. GPT-5.6 Sol via ChatGPT / Codex " + "is display-only: the harness tells the client it is connected, and nothing is connected or called" + ), + "outward_actions": "None: the harness answers the Ship clip's Approve request itself, so no email is sent", + "pointer": "Drawn by the harness to show where the scripted clicks land", + "stills": "Rendered at 2x and downscaled with Lanczos to 1440x810 WebP", + "clips": "Browser screencast frames resampled to 30 fps; VP9 WebM and H.264 MP4 (faststart), no audio", +} +CLIP_NOTES = { + "research": "Two takes at normal speed joined by a four-frame dissolve: the local-model conversation's " + "source trace, then the Knowledge graph settling and its co-op memory highlighted.", + "create": "One continuous take at normal speed: the hosted model shown in the picker, then the landing page " + "opened for editing in the Design panel.", + "automate": "Three takes at normal speed joined by four-frame dissolves: the workflow delivery defaults (no " + "external channels), the workflow's local model saved, then its retained run history.", + "ship": "One continuous take at normal speed: the waiting email approval, then Approve.", +} +# The landing page's story assets: scene id -> (scene, has clip, has MP4 fallback, alt text). +STORY_ASSETS = { + "hero-app": ("research", False, False, + "Row-Bot's Knowledge graph after local-model research, with the co-op's memory and its " + "connections highlighted."), + "research-local": ("research", True, True, + "A local-model conversation with its source trace, followed by the Knowledge graph " + "settling into connected memories."), + "synthesis-sol": ("create", True, True, + "A conversation handed to a frontier model, GPT-5.6 Sol, with an editable landing page " + "in its Design panel."), + "knowledge-control": ("research", True, False, + "A Row-Bot knowledge graph of 103 connected memories, with one memory's connections " + "labelled."), + "designer-output": ("create", True, False, + "An editable landing page open in the conversation's Design panel, its heading then " + "selected for editing."), + "workflow-repeat": ("automate", True, True, + "Workflow delivery kept in the app with no outside channels, a workflow's local model " + "saved, then its retained run history."), + "approval-boundary": ("ship", True, True, + "Row-Bot waiting for approval before emailing a PDF to riverbend-board@example.com, " + "then the approval being given."), + "model-choice": ("create", False, False, + "The composer's model pill showing GPT-5.6 Sol as a cloud model, beside the conversation's " + "Design panel."), +} + + +def _file_record(path: Path) -> tuple[str, int]: + return hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_size + + +def write_records(*, approve: bool = False) -> None: + """Hash the published media into the manifest and the recording receipt. + + New media wait for the owner's review (``pending_review``); ``approve`` + records the owner's approval of the media as published now. + """ + import imageio_ffmpeg + + timings = json.loads((SCRATCH / "timings.json").read_text(encoding="utf-8")) + digest = hashlib.sha256() + clips: dict[str, Any] = {} + for scene in SCENES: + still = MEDIA / "screenshots" / f"{scene}.webp" + webm, mp4 = MEDIA / "clips" / f"{scene}.webm", MEDIA / "clips" / f"{scene}.mp4" + frames, _seconds = imageio_ffmpeg.count_frames_and_secs(str(webm)) + record: dict[str, Any] = { + "source": "Screencast of the real Row-Bot 5.0.0 React client", + "source_frames": timings[scene]["raw_frames"], + "segments_seconds": timings[scene]["segments_seconds"], + "duration_seconds": round(frames / FPS, 3), + "dimensions": [WIDTH, HEIGHT], + "fps": FPS, + } + for key, path in (("webm", webm), ("mp4", mp4), ("poster", still)): + sha, size = _file_record(path) + digest.update(sha.encode()) + record.update({key: f"media/landing-story/{path.parent.name}/{path.name}", + f"{key}_sha256": sha, f"{key}_bytes": size}) + record["editorial_note"] = CLIP_NOTES[scene] + if "click_seconds" in timings[scene]: + record["approval_moment_seconds"] = timings[scene]["click_seconds"] + clips[scene] = record + run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + digest.hexdigest()[:6] + receipt = { + "schema": 1, + "story_id": STORY_ID, + "run_id": run_id, + "editing": ("normal-speed editorial cuts with four-frame dissolves; scripted takes of the real app, " + "no acceleration"), + "production": PRODUCTION, + "clips": clips, + } + (MEDIA / "recording-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8", newline="\n") + + manifest_path = MEDIA / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assets = [] + for asset in manifest["assets"]: + if asset["scene_id"] not in STORY_ASSETS: + assets.append(asset) + continue + scene, has_clip, has_fallback, alt = STORY_ASSETS[asset["scene_id"]] + entry = {"scene_id": asset["scene_id"], "still": f"screenshots/{scene}.webp", + "sha256": clips[scene]["poster_sha256"]} + if has_clip: + entry.update(clip=f"clips/{scene}.webm", clip_sha256=clips[scene]["webm_sha256"]) + if has_fallback: + entry.update(clip_fallback=f"clips/{scene}.mp4", clip_fallback_sha256=clips[scene]["mp4_sha256"]) + entry["alt"] = alt + assets.append(entry) + reviewed_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if approve else None + manifest.update(story_id=STORY_ID, run_id=run_id, review_status="approved" if approve else "pending_review", + reviewed_at=reviewed_at, production=PRODUCTION, assets=assets) + first = ("schema", "story_id", "run_id", "review_status", "reviewed_at", "recording_receipt", "production") + ordered = {key: manifest[key] for key in first} + ordered.update({key: value for key, value in manifest.items() if key not in ordered}) + manifest_path.write_text(json.dumps(ordered, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n") + + +def serve() -> None: + anchor = _anchor() + with ExitStack() as stack: + data_dir = Path(stack.enter_context(tempfile.TemporaryDirectory( + prefix="profile-", dir=SCRATCH, ignore_cleanup_errors=True))).resolve() + ollama = _start_demo_ollama(stack) + _helper("--seed", str(data_dir), "--ollama-host", ollama, "--anchor", anchor.isoformat()) + _helper("--add-approval", str(data_dir), "--anchor", anchor.isoformat()) + app = App(stack, data_dir, ollama) + stop = SCRATCH / "serve.stop" + stop.unlink(missing_ok=True) + (SCRATCH / "serve.json").write_text(json.dumps({"base": app.base, "anchor": anchor.isoformat()}), + encoding="utf-8") + print(f"Row-Bot is running at {app.base}/; create {stop.relative_to(ROOT)} to stop it.", flush=True) + while app.proc.poll() is None and not stop.exists(): + time.sleep(0.5) + stop.unlink(missing_ok=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--scenes", nargs="*", default=list(SCENES), choices=SCENES) + parser.add_argument("--serve", action="store_true", help="seed and start the app for a manual look") + parser.add_argument("--records", action="store_true", + help="only rewrite the manifest and recording receipt from the published media") + parser.add_argument("--approve", action="store_true", + help="with --records: record the owner's approval of the published media") + parser.add_argument("--seed", metavar="DATA_DIR", help=argparse.SUPPRESS) + parser.add_argument("--add-approval", metavar="DATA_DIR", help=argparse.SUPPRESS) + parser.add_argument("--ollama-host", default="", help=argparse.SUPPRESS) + parser.add_argument("--anchor", default="", help=argparse.SUPPRESS) + args = parser.parse_args() + anchor = datetime.fromisoformat(args.anchor) if args.anchor else _anchor() + if args.seed: + seed_profile(Path(args.seed), args.ollama_host, anchor) + elif args.add_approval: + add_ship_approval(Path(args.add_approval), anchor) + elif args.serve: + serve() + elif args.records: + write_records(approve=args.approve) + else: + print(json.dumps(capture(args.scenes), indent=2)) + write_records() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())