rikrok 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/readme/beat-flow.jpg +0 -0
  5. package/assets/readme/beat-headline.jpg +0 -0
  6. package/assets/readme/beat-next.jpg +0 -0
  7. package/assets/readme/beat-play.jpg +0 -0
  8. package/assets/readme/beat-status.jpg +0 -0
  9. package/assets/readme/beats.jpg +0 -0
  10. package/assets/wordmark.png +0 -0
  11. package/assets/wordmark.svg +1 -0
  12. package/bin/rikrok.mjs +63 -0
  13. package/launchd/com.rikrok.plist.tmpl +25 -0
  14. package/package.json +70 -0
  15. package/remotion/Reel.tsx +513 -0
  16. package/remotion/Root.tsx +71 -0
  17. package/remotion/index.ts +4 -0
  18. package/remotion/public/silence.wav +0 -0
  19. package/remotion/theme.ts +52 -0
  20. package/scripts/gen-icon.mjs +117 -0
  21. package/scripts/ui-check.mjs +76 -0
  22. package/server/feed.mjs +153 -0
  23. package/server/public/app.js +342 -0
  24. package/server/public/icon-180.png +0 -0
  25. package/server/public/icon-512.png +0 -0
  26. package/server/public/icon.svg +1 -0
  27. package/server/public/index.html +112 -0
  28. package/server/public/manifest.webmanifest +14 -0
  29. package/server/public/sw.js +22 -0
  30. package/src/cli/backfill.mjs +13 -0
  31. package/src/cli/config.mjs +29 -0
  32. package/src/cli/demo.mjs +14 -0
  33. package/src/cli/doctor.mjs +152 -0
  34. package/src/cli/feed.mjs +7 -0
  35. package/src/cli/hook.mjs +77 -0
  36. package/src/cli/install.mjs +66 -0
  37. package/src/cli/recap.mjs +66 -0
  38. package/src/cli/setup.mjs +162 -0
  39. package/src/cli/voice.mjs +214 -0
  40. package/src/cli/watch.mjs +5 -0
  41. package/src/hooks/comment.mjs +21 -0
  42. package/src/lib/backfill.mjs +40 -0
  43. package/src/lib/config.mjs +89 -0
  44. package/src/lib/evidence.mjs +21 -0
  45. package/src/lib/gitinfo.mjs +40 -0
  46. package/src/lib/llm.mjs +258 -0
  47. package/src/lib/narrate.mjs +148 -0
  48. package/src/lib/palette.mjs +27 -0
  49. package/src/lib/paths.mjs +29 -0
  50. package/src/lib/pipeline.mjs +180 -0
  51. package/src/lib/render-job.mjs +76 -0
  52. package/src/lib/script-claude.mjs +48 -0
  53. package/src/lib/state.mjs +19 -0
  54. package/src/lib/stt.mjs +26 -0
  55. package/src/lib/watcher.mjs +102 -0
  56. package/src/sources/claude.mjs +168 -0
  57. package/src/sources/index.mjs +26 -0
  58. package/src/voices/clone.mjs +66 -0
  59. package/src/voices/fx.mjs +22 -0
  60. package/src/voices/index.mjs +46 -0
  61. package/src/voices/module.mjs +18 -0
  62. package/src/voices/none.mjs +23 -0
  63. package/src/voices/openai-speech.mjs +34 -0
  64. package/src/voices/say.mjs +32 -0
  65. package/test/fixtures/claude-session.jsonl +23 -0
@@ -0,0 +1,342 @@
1
+ // Rik Rok feed: TikTok-style vertical swipe.
2
+ // SINGLE shared <video> element: iOS unlocks it once on the Start tap and it
3
+ // stays unlocked, so every swipe plays with sound — and only one video is
4
+ // ever loaded (161 videos in the DOM made Safari refuse unmuted playback
5
+ // and crawl). Cells are lightweight cards; the player moves into the active one.
6
+ let items = [];
7
+ const feed = document.getElementById("feed");
8
+ const startEl = document.getElementById("start");
9
+
10
+ // Pool of 3 video elements: one plays in the active cell, the other two sit
11
+ // detached pre-buffering the next two reels. All three are unlocked by the
12
+ // Start tap, so whichever one rotates in can play with sound instantly.
13
+ const pool = Array.from({ length: 3 }, () => {
14
+ const v = document.createElement("video");
15
+ v.playsInline = true;
16
+ v.loop = true;
17
+ v.preload = "auto";
18
+ return v;
19
+ });
20
+ let player = pool[0]; // the element currently in the active cell
21
+ let activeCell = null;
22
+ let started = false;
23
+
24
+ function srcFor(item) {
25
+ return new URL(`/reels/${item.id}.mp4`, location.origin).href;
26
+ }
27
+
28
+ function elementFor(item) {
29
+ return pool.find((v) => v.src === srcFor(item)) || pool.find((v) => v !== player) || pool[0];
30
+ }
31
+
32
+ function prebuffer() {
33
+ const idx = items.findIndex((i) => i.id === activeCell?.dataset.id);
34
+ if (idx < 0) return;
35
+ const next = [items[idx + 1], items[idx + 2]].filter(Boolean);
36
+ const spare = pool.filter((v) => v !== player);
37
+ for (let k = 0; k < spare.length; k++) {
38
+ const target = next[k];
39
+ if (!target) {
40
+ spare[k].removeAttribute("src");
41
+ continue;
42
+ }
43
+ if (spare[k].src !== srcFor(target)) {
44
+ spare[k].pause();
45
+ spare[k].src = srcFor(target);
46
+ spare[k].load();
47
+ }
48
+ }
49
+ }
50
+
51
+ async function load() {
52
+ const res = await fetch("/api/feed");
53
+ items = await res.json();
54
+ document.getElementById("count").textContent = items.length
55
+ ? `${items.filter((i) => !i.watched).length} new · ${items.length} reels`
56
+ : "no reels yet";
57
+ render();
58
+ }
59
+
60
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
61
+
62
+ const detailAvailable = (i) =>
63
+ (i.achieved?.length || i.open?.length || i.commits?.length || i.urls?.length) > 0;
64
+
65
+ function detailSheet(item) {
66
+ if (!detailAvailable(item)) return "";
67
+ const list = (title, arr, cls, mark) =>
68
+ arr?.length
69
+ ? `<div class="dsec"><div class="dtitle ${cls}">${title}</div>${arr
70
+ .map((x) => `<div class="ditem"><span class="${cls}">${mark}</span> ${esc(x)}</div>`)
71
+ .join("")}</div>`
72
+ : "";
73
+ const links = item.urls?.length
74
+ ? `<div class="dsec"><div class="dtitle">Links</div>${item.urls
75
+ .map((u) => `<a class="ditem dlink" href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace(/^https?:\/\//, ""))}</a>`)
76
+ .join("")}</div>`
77
+ : "";
78
+ return `<div class="sheet">
79
+ ${list("Shipped", item.achieved, "ok", "✓")}
80
+ ${list("Open", item.open, "warn", "○")}
81
+ ${list("Commits", item.commits, "mono", "·")}
82
+ ${links}
83
+ </div>`;
84
+ }
85
+
86
+ function cellHtml(item) {
87
+ return `
88
+ <div class="poster" style="border-color:${esc(item.accentColor)}">
89
+ <div class="pproj" style="color:${esc(item.accentColor)}">${esc(item.project)}</div>
90
+ <div class="phead">${esc(item.headline)}</div>
91
+ </div>
92
+ ${item.watched ? "" : '<div class="newpill">NEW</div>'}
93
+ ${item.newerId ? `<button class="chain" data-jump="${esc(item.newerId)}">newer recap of this session ↗</button>` : item.chainCount > 1 ? '<div class="chain latest">latest of ' + item.chainCount + ' recaps</div>' : ""}
94
+ <div class="flash">✅</div>
95
+ <div class="overlay">
96
+ <div class="proj"><span class="dot" style="background:${esc(item.accentColor)}"></span>
97
+ <span style="color:${esc(item.accentColor)}">${esc(item.project)}</span></div>
98
+ <div class="headline">${esc(item.headline)}</div>
99
+ ${item.where ? `<div class="where">${esc(item.where)}</div>` : ""}
100
+ <div class="next"><b>Next:</b> ${esc(item.next_step)}</div>
101
+ <div class="row">
102
+ <button class="btn watch">${item.watched ? "✓ watched" : "mark watched"}</button>
103
+ <button class="btn comment">💬${item.comments?.length ? " " + item.comments.length : ""}</button>
104
+ <button class="btn archive">archive</button>
105
+ <button class="btn copy">copy path</button>
106
+ ${detailAvailable(item) ? '<button class="btn details">details</button>' : ""}
107
+ </div>
108
+ ${detailSheet(item)}
109
+ <div class="csheet">
110
+ <textarea class="cinput" rows="2" placeholder="Reply to this session… (routed to the project's agent)"></textarea>
111
+ <div class="crow">
112
+ <button class="btn csend">send</button>
113
+ <span class="cstatus"></span>
114
+ </div>
115
+ ${(item.comments || []).map((c) => `<div class="citem">${esc(c.text)}${c.routedTo ? ` <span class="crouted">→ ${esc(c.routedTo)}</span>` : ""}</div>`).join("")}
116
+ </div>
117
+ </div>
118
+ <div class="speed">2×</div>`;
119
+ }
120
+
121
+ function render() {
122
+ feed.innerHTML = "";
123
+ if (!items.length) {
124
+ document.getElementById("empty").style.display = "block";
125
+ return;
126
+ }
127
+ for (const item of items) {
128
+ const cell = document.createElement("div");
129
+ cell.className = "cell";
130
+ cell.dataset.id = item.id;
131
+ cell.innerHTML = cellHtml(item);
132
+ wireCell(cell, item);
133
+ feed.appendChild(cell);
134
+ }
135
+ observe();
136
+ }
137
+
138
+ function activate(cell) {
139
+ if (activeCell === cell) return;
140
+ activeCell = cell;
141
+ const item = items.find((i) => i.id === cell.dataset.id);
142
+ if (!item) return;
143
+ const prev = player;
144
+ player = elementFor(item);
145
+ if (prev !== player) {
146
+ prev.pause();
147
+ prev.remove(); // detach from its old cell; it becomes a pre-buffer spare
148
+ }
149
+ cell.prepend(player);
150
+ if (player.src !== srcFor(item)) player.src = srcFor(item);
151
+ player.currentTime = 0;
152
+ player.playbackRate = 1;
153
+ if (started) {
154
+ player.muted = false;
155
+ player.play().catch(() => {
156
+ player.muted = true;
157
+ player.play().catch(() => {});
158
+ });
159
+ }
160
+ armAutoWatch(cell, item);
161
+ prebuffer();
162
+ }
163
+
164
+ async function setWatched(item, cell, watched) {
165
+ item.watched = watched;
166
+ cell.querySelector(".watch").textContent = watched ? "✓ watched" : "mark watched";
167
+ cell.querySelector(".watch").classList.toggle("done", watched);
168
+ const pill = cell.querySelector(".newpill");
169
+ if (pill && watched) pill.remove();
170
+ try {
171
+ await fetch(`/api/watched/${item.id}`, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({ watched }),
175
+ });
176
+ } catch {}
177
+ }
178
+
179
+ function armAutoWatch(cell, item) {
180
+ if (item.watched) return;
181
+ const el = player; // the element playing this reel right now
182
+ const onTime = () => {
183
+ if (activeCell !== cell || el !== player) {
184
+ el.removeEventListener("timeupdate", onTime);
185
+ return;
186
+ }
187
+ if (el.duration && el.currentTime / el.duration > 0.8) {
188
+ el.removeEventListener("timeupdate", onTime);
189
+ setWatched(item, cell, true);
190
+ }
191
+ };
192
+ el.addEventListener("timeupdate", onTime);
193
+ }
194
+
195
+ function wireCell(cell, item) {
196
+ cell.querySelector(".watch").addEventListener("click", (e) => {
197
+ e.stopPropagation();
198
+ setWatched(item, cell, !item.watched);
199
+ });
200
+ cell.querySelector(".comment").addEventListener("click", (e) => {
201
+ e.stopPropagation();
202
+ cell.querySelector(".csheet").classList.toggle("show");
203
+ });
204
+ cell.querySelector(".csend").addEventListener("click", async (e) => {
205
+ e.stopPropagation();
206
+ const input = cell.querySelector(".cinput");
207
+ const status = cell.querySelector(".cstatus");
208
+ const text = input.value.trim();
209
+ if (!text) return;
210
+ status.textContent = "…";
211
+ try {
212
+ const r = await fetch(`/api/comment/${item.id}`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({ text }),
216
+ });
217
+ const j = await r.json();
218
+ status.textContent = j.routedTo ? `sent → ${j.routedTo}` : "saved";
219
+ const div = document.createElement("div");
220
+ div.className = "citem";
221
+ div.textContent = text;
222
+ cell.querySelector(".crow").after(div);
223
+ cell.querySelector(".comment").textContent = `💬 ${j.count}`;
224
+ input.value = "";
225
+ } catch {
226
+ status.textContent = "failed";
227
+ }
228
+ });
229
+ cell.querySelector(".archive").addEventListener("click", async (e) => {
230
+ e.stopPropagation();
231
+ try {
232
+ await fetch(`/api/archive/${item.id}`, {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify({ archived: true }),
236
+ });
237
+ if (activeCell === cell) player.pause();
238
+ cell.remove();
239
+ items = items.filter((i) => i.id !== item.id);
240
+ } catch {}
241
+ });
242
+ const chain = cell.querySelector("button.chain");
243
+ if (chain) {
244
+ chain.addEventListener("click", (e) => {
245
+ e.stopPropagation();
246
+ const target = document.querySelector(`.cell[data-id="${chain.dataset.jump}"]`);
247
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
248
+ });
249
+ }
250
+ cell.querySelector(".copy").addEventListener("click", async (e) => {
251
+ e.stopPropagation();
252
+ try {
253
+ await navigator.clipboard.writeText(item.sourcePath || item.project);
254
+ e.target.textContent = "copied ✓";
255
+ setTimeout(() => (e.target.textContent = "copy path"), 1500);
256
+ } catch {}
257
+ });
258
+
259
+ // hold: 2x speed while pressed
260
+ let holdTimer = null;
261
+ let holding = false;
262
+ cell.addEventListener("pointerdown", (e) => {
263
+ if (e.target.closest(".btn, .overlay, .sheet, .csheet")) return;
264
+ holdTimer = setTimeout(() => {
265
+ holding = true;
266
+ player.playbackRate = 2;
267
+ cell.querySelector(".speed").classList.add("show");
268
+ }, 350);
269
+ });
270
+ const endHold = () => {
271
+ clearTimeout(holdTimer);
272
+ if (holding) {
273
+ player.playbackRate = 1;
274
+ cell.querySelector(".speed").classList.remove("show");
275
+ setTimeout(() => (holding = false), 350); // swallow the click this release fires
276
+ }
277
+ };
278
+ for (const ev of ["pointerup", "pointercancel", "pointerleave"]) cell.addEventListener(ev, endHold);
279
+
280
+ // tap: pause/resume (and unmute). double-tap: mark watched.
281
+ let lastTap = 0;
282
+ cell.addEventListener("click", (e) => {
283
+ if (holding || e.target.closest(".btn, .sheet, .csheet")) return;
284
+ const now = Date.now();
285
+ if (now - lastTap < 320) {
286
+ setWatched(item, cell, true);
287
+ const f = cell.querySelector(".flash");
288
+ f.classList.add("show");
289
+ setTimeout(() => f.classList.remove("show"), 700);
290
+ lastTap = 0;
291
+ return;
292
+ }
293
+ lastTap = now;
294
+ setTimeout(() => {
295
+ if (Date.now() - lastTap < 320 || activeCell !== cell) return;
296
+ if (player.muted) {
297
+ player.muted = false;
298
+ if (player.paused) player.play().catch(() => {});
299
+ } else if (player.paused) player.play().catch(() => {});
300
+ else player.pause();
301
+ }, 330);
302
+ });
303
+ }
304
+
305
+ let observer;
306
+ function observe() {
307
+ observer?.disconnect();
308
+ observer = new IntersectionObserver(
309
+ (entries) => {
310
+ for (const en of entries) {
311
+ if (en.intersectionRatio >= 0.6) activate(en.target);
312
+ else if (activeCell === en.target && en.intersectionRatio === 0) player.pause();
313
+ }
314
+ },
315
+ { threshold: [0, 0.6] },
316
+ );
317
+ document.querySelectorAll(".cell").forEach((c) => observer.observe(c));
318
+ }
319
+
320
+ document.getElementById("go").addEventListener("click", () => {
321
+ started = true;
322
+ startEl.remove();
323
+ const first = document.querySelector(".cell");
324
+ if (first) activate(first); // assigns srcs to player + both pre-buffer spares
325
+ // Unlock every pool element inside this gesture (play+pause blesses each
326
+ // for future unmuted playback), then start the first reel for real.
327
+ for (const v of pool) {
328
+ if (v === player) continue;
329
+ v.muted = true;
330
+ const p = v.play();
331
+ if (p) p.then(() => v.pause()).catch(() => {});
332
+ }
333
+ if (first) {
334
+ player.muted = false;
335
+ player.play().catch(() => {
336
+ player.muted = true;
337
+ player.play().catch(() => {});
338
+ });
339
+ }
340
+ });
341
+
342
+ load();
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"><defs><clipPath id="mc"><rect x="0" y="0" width="800" height="426.12"/></clipPath><clipPath id="mr"><path d="M289.8 211 L800 211 L800 490 L0 490 L0 360.45 L243.30444444444444 360.45 Z"/></clipPath></defs><rect width="512" height="512" rx="96" fill="#000"/><g transform="translate(256 256) scale(1.55369) translate(-291.18499594484996 -338.06)"><g style="mix-blend-mode:screen" transform="translate(-7.724 -5.406)"><g fill="#25F4EE"><g clip-path="url(#mr)"><path transform="translate(200 450) scale(0.146484375 -0.146484375)" d="M642 612L545 294Q524 225 524 204Q524 112 687 112L653 0L-23 0L11 112L32 112Q89 112 128.5 138Q168 164 202 275L455 1102Q465 1135 465 1153Q465 1245 322 1245L356 1356L1022 1356Q1300 1356 1415 1257.5Q1530 1159 1530 1011Q1530 902 1469.5 820.5Q1409 739 1305.5 692.5Q1202 646 1070 631Q1148 595 1225 363Q1281 196 1332.5 154Q1384 112 1466 112L1432 0L1035 0Q970 94 897 330Q854 469 822 523.5Q790 578 755.5 595Q721 612 642 612ZM675 720Q892 720 988 753Q1084 786 1137 869Q1190 952 1190 1042Q1190 1145 1118.5 1196.5Q1047 1248 904 1248Q889 1248 835 1245Z"/></g><g clip-path="url(#mc)"><g transform="translate(224.44444444444446 450) rotate(-2.127) scale(1.6269) translate(-247.20833333333334 -368)"><path transform="translate(200 450) scale(0.3 -0.3)" d="M155 269L251 609Q230 590 203.5 582Q177 574 151 574Q108 574 79 597Q50 620 50 663Q50 691 70 710.5Q90 730 121 730Q150 730 168.5 711.5Q187 693 187 662Q187 640 177 623Q177 617 189 617Q244 617 294 703L312 703L191 269Z" stroke="#25F4EE" stroke-width="8.20" stroke-linejoin="round" stroke-linecap="round" paint-order="stroke"/></g></g></g></g><g style="mix-blend-mode:screen" transform="translate(7.724 5.406)"><g fill="#FE2C55"><g clip-path="url(#mr)"><path transform="translate(200 450) scale(0.146484375 -0.146484375)" d="M642 612L545 294Q524 225 524 204Q524 112 687 112L653 0L-23 0L11 112L32 112Q89 112 128.5 138Q168 164 202 275L455 1102Q465 1135 465 1153Q465 1245 322 1245L356 1356L1022 1356Q1300 1356 1415 1257.5Q1530 1159 1530 1011Q1530 902 1469.5 820.5Q1409 739 1305.5 692.5Q1202 646 1070 631Q1148 595 1225 363Q1281 196 1332.5 154Q1384 112 1466 112L1432 0L1035 0Q970 94 897 330Q854 469 822 523.5Q790 578 755.5 595Q721 612 642 612ZM675 720Q892 720 988 753Q1084 786 1137 869Q1190 952 1190 1042Q1190 1145 1118.5 1196.5Q1047 1248 904 1248Q889 1248 835 1245Z"/></g><g clip-path="url(#mc)"><g transform="translate(224.44444444444446 450) rotate(-2.127) scale(1.6269) translate(-247.20833333333334 -368)"><path transform="translate(200 450) scale(0.3 -0.3)" d="M155 269L251 609Q230 590 203.5 582Q177 574 151 574Q108 574 79 597Q50 620 50 663Q50 691 70 710.5Q90 730 121 730Q150 730 168.5 711.5Q187 693 187 662Q187 640 177 623Q177 617 189 617Q244 617 294 703L312 703L191 269Z" stroke="#FE2C55" stroke-width="8.20" stroke-linejoin="round" stroke-linecap="round" paint-order="stroke"/></g></g></g></g><g fill="#fff"><g clip-path="url(#mr)"><path transform="translate(200 450) scale(0.146484375 -0.146484375)" d="M642 612L545 294Q524 225 524 204Q524 112 687 112L653 0L-23 0L11 112L32 112Q89 112 128.5 138Q168 164 202 275L455 1102Q465 1135 465 1153Q465 1245 322 1245L356 1356L1022 1356Q1300 1356 1415 1257.5Q1530 1159 1530 1011Q1530 902 1469.5 820.5Q1409 739 1305.5 692.5Q1202 646 1070 631Q1148 595 1225 363Q1281 196 1332.5 154Q1384 112 1466 112L1432 0L1035 0Q970 94 897 330Q854 469 822 523.5Q790 578 755.5 595Q721 612 642 612ZM675 720Q892 720 988 753Q1084 786 1137 869Q1190 952 1190 1042Q1190 1145 1118.5 1196.5Q1047 1248 904 1248Q889 1248 835 1245Z"/></g><g clip-path="url(#mc)"><g transform="translate(224.44444444444446 450) rotate(-2.127) scale(1.6269) translate(-247.20833333333334 -368)"><path transform="translate(200 450) scale(0.3 -0.3)" d="M155 269L251 609Q230 590 203.5 582Q177 574 151 574Q108 574 79 597Q50 620 50 663Q50 691 70 710.5Q90 730 121 730Q150 730 168.5 711.5Q187 693 187 662Q187 640 177 623Q177 617 189 617Q244 617 294 703L312 703L191 269Z" stroke="#fff" stroke-width="8.20" stroke-linejoin="round" stroke-linecap="round" paint-order="stroke"/></g></g></g></g></svg>
@@ -0,0 +1,112 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
6
+ <meta name="apple-mobile-web-app-capable" content="yes" />
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
8
+ <meta name="theme-color" content="#000000" />
9
+ <title>Rik Rok</title>
10
+ <link rel="manifest" href="/manifest.webmanifest" />
11
+ <link rel="apple-touch-icon" href="/icon-180.png" />
12
+ <style>
13
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
14
+ html, body { height: 100%; overflow: hidden; background: #000000; color: #ffffff;
15
+ font-family: -apple-system, "Helvetica Neue", sans-serif; overscroll-behavior: none; }
16
+ #feed { height: 100dvh; overflow-y: scroll; scroll-snap-type: y mandatory;
17
+ -webkit-overflow-scrolling: touch; scrollbar-width: none; }
18
+ #feed::-webkit-scrollbar { display: none; }
19
+ .cell { position: relative; height: 100dvh; scroll-snap-align: start; scroll-snap-stop: always;
20
+ background: #000000; overflow: hidden; }
21
+ .cell video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: 1; }
22
+ .poster { position: absolute; inset: 0; display: flex; flex-direction: column;
23
+ justify-content: center; padding: 0 32px; gap: 14px;
24
+ border-left: 6px solid transparent; }
25
+ .pproj { font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 700; }
26
+ .phead { font-size: 26px; font-weight: 700; line-height: 1.25; color: rgba(255,255,255,0.85); }
27
+ .overlay { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px 18px;
28
+ padding-bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 3;
29
+ background: linear-gradient(transparent, rgba(0,0,0,0.88));
30
+ display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
31
+ .overlay > * { pointer-events: auto; }
32
+ .proj { font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase;
33
+ font-weight: 700; display: flex; align-items: center; gap: 8px; }
34
+ .proj .dot { width: 10px; height: 10px; border-radius: 2px; }
35
+ .headline { font-size: 16px; font-weight: 600; line-height: 1.3; }
36
+ .where { font-size: 11px; font-family: ui-monospace, Menlo, monospace;
37
+ color: rgba(255,255,255,0.55); letter-spacing: 0.02em; }
38
+ .next { font-size: 13px; color: #EE1D52; line-height: 1.35; }
39
+ .sheet { display: none; max-height: 40dvh; overflow-y: auto; margin-top: 10px;
40
+ background: rgba(0,0,0,0.92); border: 1px solid rgba(255,255,255,0.18);
41
+ border-radius: 14px; padding: 14px 16px; backdrop-filter: blur(10px); }
42
+ .sheet.show { display: block; }
43
+ .dsec { margin-bottom: 12px; }
44
+ .dsec:last-child { margin-bottom: 0; }
45
+ .dtitle { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
46
+ color: rgba(255,255,255,0.5); margin-bottom: 6px; }
47
+ .ditem { font-size: 12.5px; line-height: 1.5; color: #ffffff; display: block; }
48
+ .ditem.dlink { color: #EE1D52; text-decoration: none; word-break: break-all; }
49
+ .ditem .ok { color: #69C9D0; } .ditem .warn { color: #EE1D52; }
50
+ .dsec .mono ~ .ditem, .ditem.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
51
+ .next b { color: inherit; }
52
+ .row { display: flex; gap: 10px; margin-top: 8px; align-items: center; }
53
+ .btn { border: 1px solid rgba(255,255,255,0.35); border-radius: 999px;
54
+ background: rgba(0,0,0,0.55); color: #ffffff; font-size: 12px;
55
+ padding: 7px 14px; backdrop-filter: blur(6px); }
56
+ .btn.done { border-color: #69C9D0; color: #69C9D0; }
57
+ .newpill { position: absolute; top: calc(14px + env(safe-area-inset-top)); right: 14px;
58
+ z-index: 3; font-size: 10px; letter-spacing: 0.18em; font-weight: 800;
59
+ color: #000000; background: #EE1D52; border-radius: 999px; padding: 5px 10px; }
60
+ .soundhint { position: absolute; top: calc(14px + env(safe-area-inset-top)); left: 14px;
61
+ z-index: 3; font-size: 11px; color: rgba(255,255,255,0.8);
62
+ background: rgba(0,0,0,0.6); border-radius: 999px; padding: 6px 12px; display: none; }
63
+ .chain { position: absolute; top: calc(14px + env(safe-area-inset-top)); left: 14px; z-index: 3;
64
+ font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: #000000; background: #69C9D0;
65
+ border: 0; border-radius: 999px; padding: 6px 12px; }
66
+ .chain.latest { background: rgba(0,0,0,0.6); color: rgba(255,255,255,0.8); font-weight: 600; }
67
+ #start { position: fixed; inset: 0; z-index: 10; background: #000000;
68
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
69
+ gap: 18px; }
70
+ #start h1 { font-size: 34px; font-weight: 800; letter-spacing: -0.02em; }
71
+ #start h1 span { color: #69C9D0; }
72
+ #start p { color: rgba(255,255,255,0.6); font-size: 14px; }
73
+ #start .go { margin-top: 10px; background: #69C9D0; color: #000000; font-weight: 800;
74
+ border: 0; border-radius: 999px; font-size: 16px; padding: 14px 38px; }
75
+ #empty { display: none; padding: 40vh 30px 0; text-align: center;
76
+ color: rgba(255,255,255,0.6); font-size: 15px; line-height: 1.6; }
77
+ .flash { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
78
+ z-index: 4; pointer-events: none; opacity: 0; font-size: 64px; transition: opacity 0.4s; }
79
+ .flash.show { opacity: 1; }
80
+ .speed { position: absolute; top: 50%; right: 16px; transform: translateY(-50%);
81
+ z-index: 4; pointer-events: none; opacity: 0; transition: opacity 0.15s;
82
+ font-weight: 800; font-size: 18px; color: #000000; background: #ffffff;
83
+ border-radius: 999px; padding: 8px 14px; }
84
+ .speed.show { opacity: 0.92; }
85
+ .csheet { display: none; margin-top: 10px; background: rgba(0,0,0,0.92);
86
+ border: 1px solid rgba(255,255,255,0.18); border-radius: 14px; padding: 12px 14px;
87
+ backdrop-filter: blur(10px); max-height: 40dvh; overflow-y: auto; }
88
+ .csheet.show { display: block; }
89
+ .cinput { width: 100%; background: rgba(255,255,255,0.08); color: #ffffff;
90
+ border: 1px solid rgba(255,255,255,0.22); border-radius: 10px; padding: 10px 12px;
91
+ font-size: 14px; font-family: inherit; resize: none; }
92
+ .crow { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
93
+ .cstatus { font-size: 11px; color: #69C9D0; }
94
+ .citem { font-size: 12.5px; line-height: 1.5; color: #ffffff; margin-top: 10px;
95
+ padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.12); }
96
+ .crouted { color: #69C9D0; font-size: 11px; }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <div id="start">
101
+ <h1>Rik<span> Rok</span></h1>
102
+ <p id="count">loading…</p>
103
+ <button class="go" id="go">Start scrolling</button>
104
+ </div>
105
+ <div id="feed"></div>
106
+ <div id="empty">No reels yet.<br/>The watcher will fill this as sessions go idle.</div>
107
+ <script src="/app.js"></script>
108
+ <script>
109
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js");
110
+ </script>
111
+ </body>
112
+ </html>
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Rik Rok",
3
+ "short_name": "Rik Rok",
4
+ "description": "Your coding-agent sessions as recap reels",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#000000",
8
+ "theme_color": "#000000",
9
+ "orientation": "portrait",
10
+ "icons": [
11
+ { "src": "/icon-180.png", "sizes": "180x180", "type": "image/png" },
12
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
13
+ ]
14
+ }
@@ -0,0 +1,22 @@
1
+ // Minimal service worker: network-first for the app shell, no video caching
2
+ // (reels are large and served over the LAN — caching them would bloat storage).
3
+ self.addEventListener("install", () => self.skipWaiting());
4
+ self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
5
+ self.addEventListener("fetch", (e) => {
6
+ const url = new URL(e.request.url);
7
+ if (url.pathname.startsWith("/reels/") || url.pathname.startsWith("/api/") || e.request.method !== "GET")
8
+ return;
9
+ e.respondWith(
10
+ (async () => {
11
+ const cache = await caches.open("rikrok-shell-v1");
12
+ try {
13
+ const resp = await fetch(e.request);
14
+ if (resp.ok) cache.put(e.request, resp.clone());
15
+ return resp;
16
+ } catch {
17
+ const cached = await cache.match(e.request);
18
+ return cached || Response.error();
19
+ }
20
+ })(),
21
+ );
22
+ });
@@ -0,0 +1,13 @@
1
+ import { ensureDirs } from "../lib/config.mjs";
2
+ import { loadState, saveState } from "../lib/state.mjs";
3
+ import { runBackfill } from "../lib/backfill.mjs";
4
+ export async function run(args) {
5
+ ensureDirs();
6
+ const state = loadState();
7
+ const limit = args.limit ? Number(args.limit) : 10;
8
+ const n = await runBackfill(state, { limit });
9
+ state.backfillDone = true;
10
+ saveState(state);
11
+ console.log(`[backfill] done (${n} reel(s))`);
12
+ return 0;
13
+ }
@@ -0,0 +1,29 @@
1
+ import * as c from "../lib/config.mjs";
2
+ export async function run() {
3
+ const rows = {
4
+ RIKROK_HOME: c.RIKROK_HOME,
5
+ RIKROK_CLAUDE_DIR: c.CLAUDE_PROJECTS,
6
+ RIKROK_SOURCES: c.SOURCES.join(","),
7
+ RIKROK_PORT: c.FEED_PORT,
8
+ RIKROK_BIND: c.FEED_BIND,
9
+ RIKROK_IDLE_MINUTES: c.IDLE_MINUTES,
10
+ RIKROK_MIN_TURNS: c.MIN_ASSISTANT_TURNS,
11
+ RIKROK_MIN_TOOLS: c.MIN_TOOL_USES,
12
+ RIKROK_MAX_PER_HOUR: c.MAX_REELS_PER_HOUR,
13
+ RIKROK_LLM_URL: c.LLM_URL,
14
+ RIKROK_LLM_MODEL: c.LLM_MODEL || "(unset: template scripts only)",
15
+ RIKROK_LLM_KEY: c.LLM_KEY ? "(set)" : "",
16
+ RIKROK_LLM_EXTRA: JSON.stringify(c.LLM_EXTRA),
17
+ RIKROK_VOICE: c.VOICE,
18
+ RIKROK_VOICE_FX: c.VOICE_FX,
19
+ RIKROK_TTS_URL: c.TTS_URL,
20
+ RIKROK_TTS_MODEL: c.TTS_MODEL,
21
+ RIKROK_STT_URL: c.STT_URL || "(off)",
22
+ RIKROK_STT_MODEL: c.STT_MODEL,
23
+ RIKROK_COMMENT_HOOK: c.COMMENT_HOOK || "(none)",
24
+ RIKROK_HANDLE: c.HANDLE || "(none)",
25
+ RIKROK_PROJECT_NAME_RE: c.PROJECT_NAME_RE || "(none)",
26
+ };
27
+ for (const [k, v] of Object.entries(rows)) console.log(`${k.padEnd(24)} ${v}`);
28
+ return 0;
29
+ }
@@ -0,0 +1,14 @@
1
+ // Renders one reel from the bundled fixture session, so you can see the output
2
+ // before pointing Rik Rok at your own sessions. LLM optional (template script otherwise).
3
+ import path from "node:path";
4
+ import { PKG_ROOT, ensureDirs } from "../lib/config.mjs";
5
+ import { buildReel } from "../lib/pipeline.mjs";
6
+
7
+ export async function run(args) {
8
+ ensureDirs();
9
+ const fixture = path.join(PKG_ROOT, "test", "fixtures", "claude-session.jsonl");
10
+ const outPath = path.resolve(typeof args.out === "string" ? args.out : path.join(PKG_ROOT, "assets", "demo.mp4"));
11
+ const { sidecar } = await buildReel({ source: "claude", path: fixture, sessionId: "demo-session", projectDir: "example-app" }, 0, { outPath, projectName: "example-app" });
12
+ console.log(`\ndemo reel: ${outPath}\nscript: ${sidecar.scriptSource}, voice: ${sidecar.voice}${sidecar.silent ? " (silent)" : ""}, ${sidecar.durationSec}s`);
13
+ return 0;
14
+ }