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,76 @@
1
+ // Renders one reel. Run as a child process (under `nice -n 19` where available).
2
+ // Usage: node src/lib/render-job.mjs <workDir>
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { bundle } from "@remotion/bundler";
6
+ import { ensureBrowser, renderMedia, selectComposition } from "@remotion/renderer";
7
+ import { BUNDLE_DIR, REMOTION_DIR, PKG_VERSION } from "./config.mjs";
8
+
9
+ const workDir = process.argv[2];
10
+ if (!workDir) {
11
+ console.error("usage: node src/lib/render-job.mjs <workDir>");
12
+ process.exit(2);
13
+ }
14
+ const job = JSON.parse(fs.readFileSync(path.join(workDir, "job.json"), "utf-8"));
15
+ fs.mkdirSync(path.dirname(job.outPath), { recursive: true });
16
+
17
+ // Re-bundle only when the composition sources or the package version change.
18
+ function sourceFingerprint() {
19
+ const out = [`version:${PKG_VERSION}`];
20
+ const walk = (dir) => {
21
+ for (const f of fs.readdirSync(dir)) {
22
+ const full = path.join(dir, f);
23
+ const st = fs.statSync(full);
24
+ if (st.isDirectory()) {
25
+ if (f !== "public") walk(full);
26
+ } else if (/\.(tsx?|css)$/.test(f)) out.push(`${path.relative(REMOTION_DIR, full)}:${st.mtimeMs}`);
27
+ }
28
+ };
29
+ walk(REMOTION_DIR);
30
+ return out.sort().join("|");
31
+ }
32
+
33
+ async function ensureBundle() {
34
+ const marker = path.join(BUNDLE_DIR, ".fingerprint");
35
+ const fp = sourceFingerprint();
36
+ if (fs.existsSync(marker) && fs.readFileSync(marker, "utf-8") === fp) return BUNDLE_DIR;
37
+ fs.rmSync(BUNDLE_DIR, { recursive: true, force: true });
38
+ const out = await bundle({ entryPoint: path.join(REMOTION_DIR, "index.ts"), outDir: BUNDLE_DIR, publicDir: path.join(REMOTION_DIR, "public") });
39
+ fs.writeFileSync(marker, fp);
40
+ return out;
41
+ }
42
+
43
+ // Headless Chrome: RIKROK_BROWSER points at your own binary, otherwise Remotion's
44
+ // managed download (a few hundred MB, once) with progress on stderr.
45
+ const browserExecutable = process.env.RIKROK_BROWSER || null;
46
+ let lastPct = -1;
47
+ await ensureBrowser({
48
+ browserExecutable,
49
+ onBrowserDownload: () => {
50
+ console.error("[render] downloading headless Chrome for rendering (one-off)...");
51
+ return {
52
+ version: null,
53
+ onProgress: ({ percent }) => {
54
+ const pct = Math.floor((percent || 0) * 10) * 10;
55
+ if (pct !== lastPct) {
56
+ lastPct = pct;
57
+ console.error(`[render] chrome download ${pct}%`);
58
+ }
59
+ },
60
+ };
61
+ },
62
+ });
63
+
64
+ const serveUrl = await ensureBundle();
65
+
66
+ // Drop the narration into the served public dir so staticFile() finds it.
67
+ const audioFile = `narration-${job.id}.wav`;
68
+ fs.mkdirSync(path.join(BUNDLE_DIR, "public"), { recursive: true });
69
+ fs.copyFileSync(job.audioPath, path.join(BUNDLE_DIR, "public", audioFile));
70
+
71
+ const inputProps = { ...job.props, audioFile };
72
+ const composition = await selectComposition({ serveUrl, id: job.composition || "Reel", inputProps, browserExecutable });
73
+ await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: job.outPath, inputProps, concurrency: 4, audioCodec: "aac", browserExecutable });
74
+
75
+ fs.rmSync(path.join(BUNDLE_DIR, "public", audioFile), { force: true });
76
+ console.log(`rendered ${job.outPath}`);
@@ -0,0 +1,48 @@
1
+ // Script writer that uses your Claude Code subscription: a headless `claude -p` call with
2
+ // the same system prompt and evidence the local-model path uses. No local model needed.
3
+ // RIKROK_HOOK_SKIP=1 rides along in the environment so the SessionEnd hook ignores this session
4
+ // and a recap can never trigger another recap.
5
+ import { spawn } from "node:child_process";
6
+ import { CLAUDE_BIN, CLAUDE_MODEL } from "./config.mjs";
7
+
8
+ export function claudeAvailable() {
9
+ return new Promise((resolve) => {
10
+ const p = spawn(CLAUDE_BIN, ["--version"], { stdio: "ignore" });
11
+ p.on("error", () => resolve(false));
12
+ p.on("close", (code) => resolve(code === 0));
13
+ });
14
+ }
15
+
16
+ // messages: [{role:"system"|"user", content}] -> assistant text
17
+ export function claudeChat(messages, { timeoutMs = 240_000 } = {}) {
18
+ const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
19
+ const user = messages.filter((m) => m.role !== "system").map((m) => m.content).join("\n\n");
20
+ const args = ["-p", "--no-session-persistence", "--output-format", "json", "--model", CLAUDE_MODEL];
21
+ if (system) args.push("--append-system-prompt", system);
22
+ return new Promise((resolve, reject) => {
23
+ const p = spawn(CLAUDE_BIN, args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, RIKROK_HOOK_SKIP: "1" } });
24
+ let out = "", err = "";
25
+ const t = setTimeout(() => {
26
+ p.kill("SIGKILL");
27
+ reject(new Error(`claude -p timed out after ${timeoutMs / 1000}s`));
28
+ }, timeoutMs);
29
+ p.stdout.on("data", (d) => (out += d));
30
+ p.stderr.on("data", (d) => (err += d));
31
+ p.on("error", (e) => {
32
+ clearTimeout(t);
33
+ reject(new Error(`cannot run ${CLAUDE_BIN}: ${e.message}`));
34
+ });
35
+ p.on("close", (code) => {
36
+ clearTimeout(t);
37
+ if (code !== 0) return reject(new Error(`claude -p exited ${code}: ${(err || out).slice(0, 200)}`));
38
+ try {
39
+ const j = JSON.parse(out);
40
+ if (j.is_error) return reject(new Error(`claude: ${String(j.result).slice(0, 200)}`));
41
+ resolve(String(j.result ?? ""));
42
+ } catch {
43
+ resolve(out); // text fallback
44
+ }
45
+ });
46
+ p.stdin.end(user);
47
+ });
48
+ }
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import { STATE_FILE } from "./config.mjs";
3
+
4
+ export function loadState() {
5
+ try {
6
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8"));
7
+ } catch {
8
+ return { backfillDone: false, sessions: {} };
9
+ }
10
+ }
11
+
12
+ export function saveState(state) {
13
+ const tmp = STATE_FILE + ".tmp";
14
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
15
+ fs.renameSync(tmp, STATE_FILE);
16
+ }
17
+
18
+ // State is keyed per source so two agents' session ids can never collide.
19
+ export const sessionKey = (f) => `${f.source}:${f.sessionId}`;
@@ -0,0 +1,26 @@
1
+ // Optional transcribe-back QA for narration via an OpenAI-compatible
2
+ // /v1/audio/transcriptions endpoint with word timestamps. Off unless RIKROK_STT_URL is set.
3
+ import fs from "node:fs";
4
+ import { STT_URL, STT_MODEL, STT_KEY, authHeaders } from "./config.mjs";
5
+
6
+ export const sttEnabled = () => Boolean(STT_URL);
7
+
8
+ export async function transcribeWords(wavPath) {
9
+ if (!STT_URL) return [];
10
+ const fd = new FormData();
11
+ fd.append("file", new Blob([fs.readFileSync(wavPath)]), "audio.wav");
12
+ fd.append("model", STT_MODEL);
13
+ fd.append("response_format", "verbose_json");
14
+ fd.append("word_timestamps", "true");
15
+ try {
16
+ const resp = await fetch(`${STT_URL}/v1/audio/transcriptions`, { method: "POST", headers: authHeaders(STT_KEY), body: fd, signal: AbortSignal.timeout(120_000) });
17
+ if (!resp.ok) return [];
18
+ const j = await resp.json();
19
+ const words = [];
20
+ for (const seg of j.segments ?? []) for (const w of seg.words ?? []) words.push({ w: String(w.word).trim(), s: +w.start, e: +w.end });
21
+ for (const w of j.words ?? []) words.push({ w: String(w.word).trim(), s: +w.start, e: +w.end });
22
+ return words.filter((x) => x.w && Number.isFinite(x.s));
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
@@ -0,0 +1,102 @@
1
+ // The watcher: scans every session source (read-only), spots sessions idle for
2
+ // IDLE_MINUTES with meaningful new work, and pushes each through the pipeline one
3
+ // at a time. Retries with backoff when the LLM or voice server is down. First run
4
+ // backfills the most recent qualifying sessions so the feed is never empty.
5
+ import { ensureDirs, IDLE_MINUTES, MAX_REELS_PER_HOUR } from "./config.mjs";
6
+ import { sources } from "../sources/index.mjs";
7
+ import { loadState, saveState, sessionKey } from "./state.mjs";
8
+ import { buildReel } from "./pipeline.mjs";
9
+ import { runBackfill } from "./backfill.mjs";
10
+
11
+ const SCAN_INTERVAL_MS = 60_000;
12
+ const MAX_RETRIES = 5;
13
+ // Sessions already idle for longer than this are backlog, not news; history is
14
+ // the backfill's job, so the first scan never floods the feed with old sessions.
15
+ const BACKLOG_CUTOFF_MS = 48 * 3600_000;
16
+
17
+ export async function scanOnce(ctx) {
18
+ if (ctx.busy) return;
19
+ ctx.busy = true;
20
+ try {
21
+ const state = loadState();
22
+ if (!state.backfillDone) {
23
+ console.log("[watcher] first run, backfilling...");
24
+ await runBackfill(state);
25
+ state.backfillDone = true;
26
+ saveState(state);
27
+ console.log("[watcher] backfill complete");
28
+ }
29
+
30
+ const now = Date.now();
31
+ const idleCutoff = now - IDLE_MINUTES * 60_000;
32
+ for (const src of sources()) {
33
+ for (const f of src.listSessions()) {
34
+ if (f.mtimeMs > idleCutoff) continue; // still active
35
+ const key = sessionKey(f);
36
+ const st = state.sessions[key];
37
+ if (st && st.lastMtimeMs >= f.mtimeMs) continue; // this idle period already recapped
38
+ if (now - f.mtimeMs > BACKLOG_CUTOFF_MS) {
39
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: st?.lastLine || 0, backlog: true };
40
+ saveState(state);
41
+ continue;
42
+ }
43
+ const r = ctx.retryAt.get(key);
44
+ if (r && now < r.nextTry) continue;
45
+
46
+ const fromLine = st?.lastLine || 0;
47
+ let act;
48
+ try {
49
+ act = await src.parseSession(f.path, fromLine);
50
+ } catch (err) {
51
+ console.error(`[watcher] parse failed ${key}: ${err.message}`);
52
+ continue;
53
+ }
54
+ if (!src.qualifies(act)) {
55
+ // Not meaningful: remember the mtime so we don't re-parse every scan,
56
+ // but keep the line offset so a later burst of real work gets recapped.
57
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: fromLine, skipped: true };
58
+ saveState(state);
59
+ continue;
60
+ }
61
+
62
+ while (ctx.reelTimes.length && now - ctx.reelTimes[0] > 3600_000) ctx.reelTimes.shift();
63
+ if (ctx.reelTimes.length >= MAX_REELS_PER_HOUR) {
64
+ console.log(`[watcher] rate limit (${MAX_REELS_PER_HOUR}/h) reached, deferring remaining sessions`);
65
+ return;
66
+ }
67
+ console.log(`[watcher] recapping ${key} (lines ${fromLine}..)`);
68
+ ctx.reelTimes.push(now);
69
+ try {
70
+ const { totalLines } = await buildReel(f, fromLine);
71
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: totalLines, lastRecapAt: new Date().toISOString() };
72
+ saveState(state);
73
+ ctx.retryAt.delete(key);
74
+ } catch (err) {
75
+ const attempts = (r?.attempts || 0) + 1;
76
+ const backoffMin = Math.min(60, 2 ** attempts);
77
+ console.error(`[watcher] reel failed for ${key} (attempt ${attempts}): ${err.message}, retry in ${backoffMin}min`);
78
+ if (attempts >= MAX_RETRIES) {
79
+ console.error(`[watcher] giving up on ${key} this idle period`);
80
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: fromLine, failed: true };
81
+ saveState(state);
82
+ ctx.retryAt.delete(key);
83
+ } else {
84
+ ctx.retryAt.set(key, { attempts, nextTry: now + backoffMin * 60_000 });
85
+ }
86
+ }
87
+ }
88
+ }
89
+ } catch (err) {
90
+ console.error(`[watcher] scan error: ${err.message}`);
91
+ } finally {
92
+ ctx.busy = false;
93
+ }
94
+ }
95
+
96
+ export function startWatcher() {
97
+ ensureDirs();
98
+ const ctx = { busy: false, retryAt: new Map(), reelTimes: [] };
99
+ console.log(`[watcher] started: scanning every ${SCAN_INTERVAL_MS / 1000}s, idle bar ${IDLE_MINUTES}min, sources ${sources().map((s) => s.id).join(",")}`);
100
+ scanOnce(ctx);
101
+ return setInterval(() => scanOnce(ctx), SCAN_INTERVAL_MS);
102
+ }
@@ -0,0 +1,168 @@
1
+ // Claude Code sessions: ~/.claude/projects/<encoded cwd>/<session>.jsonl, read-only.
2
+ // Never writes anything under that directory.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import readline from "node:readline";
7
+ import { CLAUDE_PROJECTS, MIN_ASSISTANT_TURNS, MIN_TOOL_USES, PROJECT_NAME_RE } from "../lib/config.mjs";
8
+
9
+ export const id = "claude";
10
+
11
+ export function listSessions() {
12
+ const out = [];
13
+ let dirs = [];
14
+ try {
15
+ dirs = fs.readdirSync(CLAUDE_PROJECTS);
16
+ } catch {
17
+ return out;
18
+ }
19
+ for (const d of dirs) {
20
+ const full = path.join(CLAUDE_PROJECTS, d);
21
+ let files;
22
+ try {
23
+ files = fs.readdirSync(full);
24
+ } catch {
25
+ continue;
26
+ }
27
+ for (const f of files) {
28
+ if (!f.endsWith(".jsonl")) continue;
29
+ const p = path.join(full, f);
30
+ let st;
31
+ try {
32
+ st = fs.statSync(p);
33
+ } catch {
34
+ continue;
35
+ }
36
+ out.push({ source: id, path: p, sessionId: f.replace(/\.jsonl$/, ""), projectDir: d, mtimeMs: st.mtimeMs, size: st.size });
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Parse a session file from a given line offset into the shared Activity shape.
43
+ export async function parseSession(filePath, fromLine = 0) {
44
+ const rl = readline.createInterface({ input: fs.createReadStream(filePath, { encoding: "utf-8" }), crlfDelay: Infinity });
45
+
46
+ const act = {
47
+ totalLines: 0,
48
+ fromLine,
49
+ assistantTurns: 0,
50
+ toolUses: 0,
51
+ firstTs: null,
52
+ lastTs: null,
53
+ activeMs: 0, // sum of inter-event gaps capped at 5 min: honest working time
54
+ cwd: null,
55
+ gitBranch: null,
56
+ userPrompts: [], // {ts, text}
57
+ assistantTexts: [], // {ts, text}
58
+ tools: [], // {ts, name, detail}
59
+ filesTouched: new Set(),
60
+ commands: [], // {ts, command, desc}
61
+ urls: new Set(), // URLs surfaced in assistant text (deploy targets, live endpoints)
62
+ todoSnapshots: [], // last TodoWrite payload wins
63
+ };
64
+
65
+ let lineNo = -1;
66
+ for await (const line of rl) {
67
+ lineNo++;
68
+ act.totalLines = lineNo + 1;
69
+ if (lineNo < fromLine) continue;
70
+ if (!line.trim()) continue;
71
+ let d;
72
+ try {
73
+ d = JSON.parse(line);
74
+ } catch {
75
+ continue;
76
+ }
77
+ const ts = d.timestamp ? Date.parse(d.timestamp) : null;
78
+ if (ts) {
79
+ if (!act.firstTs) act.firstTs = ts;
80
+ if (act.lastTs && ts > act.lastTs) act.activeMs += Math.min(ts - act.lastTs, 5 * 60_000);
81
+ act.lastTs = ts;
82
+ }
83
+ if (d.cwd) act.cwd = d.cwd;
84
+ if (d.gitBranch) act.gitBranch = d.gitBranch;
85
+ if (d.isSidechain) continue; // subagent traffic is not the story
86
+
87
+ if (d.type === "user" && d.message) {
88
+ const content = d.message.content;
89
+ let text = null;
90
+ if (typeof content === "string") text = content;
91
+ else if (Array.isArray(content)) {
92
+ const t = content.find((c) => c.type === "text");
93
+ if (t) text = t.text;
94
+ }
95
+ // Skip tool results and injected system-ish content
96
+ if (text && !text.startsWith("<") && d.userType !== "internal") act.userPrompts.push({ ts, text: text.slice(0, 400) });
97
+ } else if (d.type === "assistant" && d.message) {
98
+ const content = d.message.content;
99
+ if (!Array.isArray(content)) continue;
100
+ let counted = false;
101
+ for (const c of content) {
102
+ if (c.type === "text" && c.text && c.text.trim()) {
103
+ if (!counted) {
104
+ act.assistantTurns++;
105
+ counted = true;
106
+ }
107
+ act.assistantTexts.push({ ts, text: c.text.slice(0, 700) });
108
+ for (const m of c.text.matchAll(/https?:\/\/[^\s)\]>"'`]+/g)) {
109
+ const u = m[0].replace(/[.,;:*`]+$/, "");
110
+ if (!/localhost|127\.0\.0\.1|github\.com\/anthropics|docs\./.test(u)) act.urls.add(u);
111
+ }
112
+ } else if (c.type === "tool_use") {
113
+ if (!counted) {
114
+ act.assistantTurns++;
115
+ counted = true;
116
+ }
117
+ act.toolUses++;
118
+ const name = c.name || "tool";
119
+ const input = c.input || {};
120
+ let detail = "";
121
+ if (input.file_path) {
122
+ detail = input.file_path;
123
+ if (["Edit", "Write", "NotebookEdit", "MultiEdit"].includes(name)) act.filesTouched.add(input.file_path);
124
+ } else if (input.command) {
125
+ detail = String(input.command).slice(0, 120);
126
+ act.commands.push({ ts, command: detail, desc: input.description || "" });
127
+ } else if (input.prompt) {
128
+ detail = String(input.prompt).slice(0, 120);
129
+ } else if (Array.isArray(input.todos)) {
130
+ act.todoSnapshots = input.todos;
131
+ } else if (input.pattern) {
132
+ detail = input.pattern;
133
+ } else if (input.url) {
134
+ detail = input.url;
135
+ }
136
+ act.tools.push({ ts, name, detail });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ act.filesTouched = [...act.filesTouched];
142
+ act.urls = [...act.urls];
143
+ return act;
144
+ }
145
+
146
+ export function qualifies(act) {
147
+ return act.assistantTurns >= MIN_ASSISTANT_TURNS && act.toolUses >= MIN_TOOL_USES;
148
+ }
149
+
150
+ // Human project name: RIKROK_PROJECT_NAME_RE capture group on the cwd if set
151
+ // (e.g. "workspaces/([^/]+)/" for worktree layouts), else the cwd's last segment.
152
+ export function projectName(act, projectDir) {
153
+ if (act.cwd) {
154
+ if (PROJECT_NAME_RE) {
155
+ try {
156
+ const m = act.cwd.match(new RegExp(PROJECT_NAME_RE));
157
+ if (m && m[1]) return m[1];
158
+ } catch {}
159
+ }
160
+ const parts = act.cwd.split("/").filter(Boolean);
161
+ return parts[parts.length - 1] || projectDir;
162
+ }
163
+ // Encoded dir name: "/" became "-". Best effort: drop the home prefix.
164
+ const homeEnc = os.homedir().replace(/\//g, "-");
165
+ return projectDir.startsWith(homeEnc) ? projectDir.slice(homeEnc.length).replace(/^-/, "") || projectDir : projectDir;
166
+ }
167
+
168
+ export const resumeHint = (sessionId) => `claude --resume ${sessionId}`;
@@ -0,0 +1,26 @@
1
+ // Session sources. Each module exports:
2
+ // id "claude" | "codex" | ...
3
+ // listSessions() -> [{ source, path, sessionId, projectDir, mtimeMs, size }]
4
+ // parseSession(path, fromLine) -> Activity (see claude.mjs for the shape)
5
+ // qualifies(act) -> boolean
6
+ // projectName(act, projectDir) -> string
7
+ // resumeHint(sessionId) -> string shown to humans and comment hooks
8
+ // Enable with RIKROK_SOURCES=claude,codex (default: claude).
9
+ import { SOURCES } from "../lib/config.mjs";
10
+ import * as claude from "./claude.mjs";
11
+
12
+ const REGISTRY = { claude };
13
+
14
+ export function sourceById(id) {
15
+ const s = REGISTRY[id];
16
+ if (!s) throw new Error(`unknown session source "${id}" (known: ${Object.keys(REGISTRY).join(", ")})`);
17
+ return s;
18
+ }
19
+
20
+ export function sources() {
21
+ return SOURCES.map(sourceById);
22
+ }
23
+
24
+ export function listAllSessions() {
25
+ return sources().flatMap((s) => s.listSessions());
26
+ }
@@ -0,0 +1,66 @@
1
+ // Your own voice: a 15 to 20 second reference clip plus its transcript, sent with every
2
+ // request to a local speech server that supports zero-shot cloning (oMLX with Qwen3-TTS
3
+ // today; any server that accepts ref_audio / ref_text on /v1/audio/speech).
4
+ // Set up with `rikrok voice setup`. Nothing is trained and nothing leaves the machine.
5
+ import fs from "node:fs";
6
+ import { TTS_URL, TTS_KEY, CLONE_MODEL, CLONE_REF, CLONE_TEXT, CLONE_API, authHeaders } from "../lib/config.mjs";
7
+
8
+ export function cloneVoice() {
9
+ const refText = () => {
10
+ try {
11
+ return fs.readFileSync(CLONE_TEXT, "utf-8").trim();
12
+ } catch {
13
+ return "";
14
+ }
15
+ };
16
+ return {
17
+ name: "clone",
18
+ async available() {
19
+ if (!fs.existsSync(CLONE_REF)) return { ok: false, reason: `no reference clip at ${CLONE_REF} (run \`rikrok voice setup\`)` };
20
+ if (!refText()) return { ok: false, reason: `no transcript at ${CLONE_TEXT} (run \`rikrok voice setup\`)` };
21
+ try {
22
+ const r = await fetch(`${TTS_URL}/v1/models`, { headers: authHeaders(TTS_KEY), signal: AbortSignal.timeout(5000) });
23
+ if (!r.ok) return { ok: false, reason: `HTTP ${r.status} from ${TTS_URL}/v1/models` };
24
+ return { ok: true };
25
+ } catch (e) {
26
+ return { ok: false, reason: `${TTS_URL} unreachable (${e.message})` };
27
+ }
28
+ },
29
+ async synth(text, outPath) {
30
+ try {
31
+ if (CLONE_API === "voice-clone") {
32
+ // Qwen3-TTS OpenAI FastAPI server (what `rikrok voice serve` runs): dedicated endpoint.
33
+ const r = await fetch(`${TTS_URL}/v1/audio/voice-clone`, {
34
+ method: "POST",
35
+ headers: { ...authHeaders(TTS_KEY), "Content-Type": "application/json" },
36
+ body: JSON.stringify({ input: text, ref_audio: fs.readFileSync(CLONE_REF).toString("base64"), ref_text: refText(), x_vector_only_mode: false, language: "English", response_format: "wav", speed: 1.0 }),
37
+ signal: AbortSignal.timeout(300_000),
38
+ });
39
+ if (!r.ok) return { ok: false, error: `HTTP ${r.status}: ${(await r.text()).slice(0, 200)}` };
40
+ fs.writeFileSync(outPath, Buffer.from(await r.arrayBuffer()));
41
+ return { ok: true };
42
+ }
43
+ const body = {
44
+ model: CLONE_MODEL,
45
+ input: text,
46
+ ref_audio: fs.readFileSync(CLONE_REF).toString("base64"),
47
+ ref_text: refText(),
48
+ temperature: 0.3,
49
+ top_p: 0.85,
50
+ response_format: "wav",
51
+ };
52
+ const r = await fetch(`${TTS_URL}/v1/audio/speech`, {
53
+ method: "POST",
54
+ headers: { ...authHeaders(TTS_KEY), "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ signal: AbortSignal.timeout(180_000),
57
+ });
58
+ if (!r.ok) return { ok: false, error: `HTTP ${r.status}: ${(await r.text()).slice(0, 200)}` };
59
+ fs.writeFileSync(outPath, Buffer.from(await r.arrayBuffer()));
60
+ return { ok: true };
61
+ } catch (e) {
62
+ return { ok: false, error: e.message };
63
+ }
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,22 @@
1
+ // Optional robot post-processing, in place. RIKROK_VOICE_FX = none | light | strong.
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+
5
+ export function applyFx(wavPath, level = "none") {
6
+ if (!level || level === "none") return;
7
+ const out = wavPath.replace(/\.wav$/, "_fx.wav");
8
+ const chain =
9
+ level === "strong"
10
+ ? [
11
+ "atempo=1.04",
12
+ "acompressor=threshold=-20dB:ratio=4:attack=5:release=90:makeup=4",
13
+ "asplit[a][b];[b]tremolo=f=70:d=1,volume=0.35[m];[a][m]amix=inputs=2:normalize=0",
14
+ "highpass=f=120",
15
+ "lowpass=f=6500",
16
+ "aecho=0.7:0.25:12:0.12",
17
+ "loudnorm=I=-20:TP=-1.5:LRA=8",
18
+ ]
19
+ : ["atempo=1.03", "acompressor=threshold=-18dB:ratio=3:attack=8:release=120:makeup=3", "tremolo=f=40:d=0.05", "highpass=f=100", "lowpass=f=8500", "loudnorm=I=-20:TP=-1.5:LRA=9"];
20
+ execFileSync("ffmpeg", ["-y", "-i", wavPath, "-af", chain.join(","), "-ar", "24000", "-ac", "1", out], { stdio: "ignore" });
21
+ fs.renameSync(out, wavPath);
22
+ }
@@ -0,0 +1,46 @@
1
+ // Voice backends. Spec strings (RIKROK_VOICE):
2
+ // clone your own voice from a reference clip (rikrok voice setup)
3
+ // say:<Voice> macOS `say` (default on a Mac)
4
+ // openai-speech:<voice> any OpenAI-compatible /v1/audio/speech server
5
+ // module:<path> your own module (private voice clones live here)
6
+ // none silent reel, captions only
7
+ import { VOICE } from "../lib/config.mjs";
8
+ import { saySpeech } from "./say.mjs";
9
+ import { openaiSpeech } from "./openai-speech.mjs";
10
+ import { moduleVoice } from "./module.mjs";
11
+ import { silentVoice } from "./none.mjs";
12
+ import { cloneVoice } from "./clone.mjs";
13
+
14
+ export const VOICE_SPECS = ["clone", "say:<Voice>", "openai-speech:<voice>", "module:<path>", "none"];
15
+
16
+ export async function loadVoice(spec = VOICE) {
17
+ if (spec === "none") return silentVoice();
18
+ if (spec === "clone") return cloneVoice();
19
+ if (spec === "say") return saySpeech();
20
+ if (spec.startsWith("say:")) return saySpeech(spec.slice(4));
21
+ if (spec === "openai-speech") return openaiSpeech();
22
+ if (spec.startsWith("openai-speech:")) return openaiSpeech(spec.slice("openai-speech:".length));
23
+ if (spec.startsWith("module:")) return moduleVoice(spec.slice(7));
24
+ throw new Error(`unknown voice "${spec}" (use one of: ${VOICE_SPECS.join(", ")})`);
25
+ }
26
+
27
+ // Configured voice, else macOS say, else silence. Never throws.
28
+ export async function resolveVoice(spec = VOICE, log = console.log) {
29
+ const chain = [spec];
30
+ if (process.platform === "darwin" && !spec.startsWith("say") && spec !== "none") chain.push("say:Samantha");
31
+ if (spec !== "none") chain.push("none");
32
+ for (const s of chain) {
33
+ try {
34
+ const v = await loadVoice(s);
35
+ const a = await v.available();
36
+ if (a.ok) {
37
+ if (s !== spec) log(`[voice] ${spec} unavailable, using ${s}`);
38
+ return v;
39
+ }
40
+ log(`[voice] ${s} unavailable: ${a.reason}`);
41
+ } catch (e) {
42
+ log(`[voice] ${s} failed to load: ${e.message}`);
43
+ }
44
+ }
45
+ return silentVoice();
46
+ }
@@ -0,0 +1,18 @@
1
+ // Bring your own voice: RIKROK_VOICE=module:/path/to/voice.mjs
2
+ // The module's default export is { name?, available?(), synth(text, outWavPath) -> {ok, error?} }.
3
+ // Keeps private engines (a personal voice clone, say) out of this repo entirely.
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { expandHome } from "../lib/paths.mjs";
7
+
8
+ export async function moduleVoice(spec) {
9
+ const p = path.resolve(expandHome(spec));
10
+ const m = await import(pathToFileURL(p).href);
11
+ const v = m.default ?? m.voice ?? m;
12
+ if (typeof v.synth !== "function") throw new Error(`voice module ${p} must export default { synth(text, outPath) }`);
13
+ return {
14
+ name: v.name || `module:${path.basename(p)}`,
15
+ available: typeof v.available === "function" ? () => v.available() : async () => ({ ok: true }),
16
+ synth: (text, out) => v.synth(text, out),
17
+ };
18
+ }
@@ -0,0 +1,23 @@
1
+ // Silent narration: captions carry the reel, timing comes from word count.
2
+ // Last link of the fallback chain so a reel always ships.
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ export const WORDS_PER_SECOND = 2.7;
6
+
7
+ export function silentVoice() {
8
+ return {
9
+ name: "none",
10
+ async available() {
11
+ return { ok: true };
12
+ },
13
+ async synth(text, outPath) {
14
+ const secs = Math.max(1.2, String(text).split(/\s+/).filter(Boolean).length / WORDS_PER_SECOND);
15
+ try {
16
+ execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", secs.toFixed(2), outPath], { stdio: "ignore" });
17
+ return { ok: true, silent: true };
18
+ } catch (e) {
19
+ return { ok: false, error: e.message };
20
+ }
21
+ },
22
+ };
23
+ }