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,152 @@
1
+ // rikrok doctor: every dependency, one line each, with the fix when it fails.
2
+ import fs from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import * as c from "../lib/config.mjs";
7
+ import { sources } from "../sources/index.mjs";
8
+ import { loadVoice } from "../voices/index.mjs";
9
+ import { chat, stripThinking, extractJson, scriptBackend } from "../lib/llm.mjs";
10
+ import { claudeChat } from "../lib/script-claude.mjs";
11
+ import { sttEnabled, transcribeWords } from "../lib/stt.mjs";
12
+
13
+ const ok = (label, detail = "") => console.log(` ok ${label}${detail ? ` (${detail})` : ""}`);
14
+ const warn = (label, fix = "") => console.log(` warn ${label}${fix ? `\n ${fix}` : ""}`);
15
+ const fail = (label, fix = "") => console.log(` FAIL ${label}${fix ? `\n ${fix}` : ""}`);
16
+
17
+ function has(bin) {
18
+ try {
19
+ execFileSync(bin, ["-version"], { stdio: "ignore" });
20
+ return true;
21
+ } catch {
22
+ try {
23
+ execFileSync(bin, ["--version"], { stdio: "ignore" });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ }
30
+
31
+ function findChrome() {
32
+ const roots = [path.join(c.PKG_ROOT, "node_modules", ".remotion"), path.join(c.PKG_ROOT, "..", ".remotion")];
33
+ const walk = (dir, depth) => {
34
+ if (depth > 6) return null;
35
+ let entries = [];
36
+ try {
37
+ entries = fs.readdirSync(dir, { withFileTypes: true });
38
+ } catch {
39
+ return null;
40
+ }
41
+ for (const e of entries) {
42
+ const full = path.join(dir, e.name);
43
+ if (e.isFile() && /^chrome-headless-shell(\.exe)?$/.test(e.name)) return full;
44
+ if (e.isDirectory()) {
45
+ const hit = walk(full, depth + 1);
46
+ if (hit) return hit;
47
+ }
48
+ }
49
+ return null;
50
+ };
51
+ for (const r of roots) {
52
+ const hit = walk(r, 0);
53
+ if (hit) return hit;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function portFree(port, host) {
59
+ return new Promise((resolve) => {
60
+ const s = net.createServer();
61
+ s.once("error", () => resolve(false));
62
+ s.listen(port, host, () => s.close(() => resolve(true)));
63
+ });
64
+ }
65
+
66
+ export async function run(args) {
67
+ let failures = 0;
68
+ console.log(`rikrok ${c.PKG_VERSION} doctor (home: ${c.RIKROK_HOME})\n`);
69
+
70
+ ok(`node ${process.versions.node}`);
71
+ for (const b of ["ffmpeg", "ffprobe", "git"]) {
72
+ if (has(b)) ok(b);
73
+ else {
74
+ fail(`${b} not found`, b === "git" ? "install git" : "install ffmpeg (brew install ffmpeg / apt install ffmpeg)");
75
+ failures++;
76
+ }
77
+ }
78
+
79
+ for (const src of sources()) {
80
+ let n = 0;
81
+ try {
82
+ n = src.listSessions().length;
83
+ } catch {}
84
+ if (n > 0) ok(`${src.id} sessions`, `${n} file(s)`);
85
+ else if (src.id === "claude") {
86
+ warn(`no Claude Code sessions under ${c.CLAUDE_PROJECTS}`, "run a Claude Code session first, or set RIKROK_CLAUDE_DIR");
87
+ } else warn(`no ${src.id} sessions found`);
88
+ }
89
+
90
+ const backend = await scriptBackend();
91
+ if (backend === "claude") {
92
+ const t0 = Date.now();
93
+ try {
94
+ const text = await claudeChat([{ role: "user", content: 'Reply with exactly this JSON and nothing else: {"ok":true}' }], { timeoutMs: 90_000 });
95
+ const j = extractJson(text);
96
+ if (j && j.ok === true) ok(`scripts by Claude Code (${c.CLAUDE_MODEL})`, `${Date.now() - t0} ms`);
97
+ else warn(`Claude Code answered but not with clean JSON: ${JSON.stringify(text).slice(0, 80)}`);
98
+ } catch (err) {
99
+ fail(`scripts by Claude Code: ${err.message}`, "is `claude` installed and logged in? Or set RIKROK_SCRIPT=local with a local model");
100
+ failures++;
101
+ }
102
+ } else if (backend === "none") {
103
+ warn("no script writer: reels fall back to the plain template", "install Claude Code (RIKROK_SCRIPT=claude) or set RIKROK_LLM_URL and RIKROK_LLM_MODEL for a local model");
104
+ } else {
105
+ const t0 = Date.now();
106
+ try {
107
+ const text = await chat([{ role: "user", content: 'Reply with exactly this JSON and nothing else: {"ok":true}' }], { temperature: 0, max_tokens: 64, timeoutMs: 60_000 });
108
+ const j = extractJson(stripThinking(text));
109
+ if (j && j.ok === true) ok(`LLM ${c.LLM_MODEL} at ${c.LLM_URL}`, `${Date.now() - t0} ms, JSON parsed`);
110
+ else {
111
+ warn(`LLM answered but not with clean JSON: ${JSON.stringify(text).slice(0, 80)}`, 'if this is a thinking model, try RIKROK_LLM_EXTRA=\'{"chat_template_kwargs":{"enable_thinking":false}}\' (oMLX/vLLM) or \'{"think":false}\' (Ollama)');
112
+ }
113
+ } catch (err) {
114
+ fail(`LLM ${c.LLM_MODEL} at ${c.LLM_URL}: ${err.message}`, "is the server running? Ollama: `ollama serve` then `ollama pull <model>`; the template script ships until it works");
115
+ failures++;
116
+ }
117
+ }
118
+
119
+ try {
120
+ const v = await loadVoice(c.VOICE);
121
+ const a = await v.available();
122
+ if (a.ok) ok(`voice ${v.name}`);
123
+ else {
124
+ warn(`voice ${c.VOICE} unavailable: ${a.reason}`, c.VOICE === "clone" ? "run `rikrok voice setup`, and point RIKROK_TTS_URL at a server that accepts a reference clip" : process.platform === "darwin" ? "falls back to say:Samantha, then silent captions" : "falls back to silent captions (rikrok voice setup for your own voice, or RIKROK_VOICE=openai-speech:<voice>)");
125
+ }
126
+ } catch (err) {
127
+ warn(`voice ${c.VOICE}: ${err.message}`);
128
+ }
129
+
130
+ if (sttEnabled()) {
131
+ try {
132
+ const r = await fetch(`${c.STT_URL}/v1/models`, { headers: c.authHeaders(c.STT_KEY), signal: AbortSignal.timeout(5000) });
133
+ if (r.ok) ok(`narration QA via ${c.STT_URL} (${c.STT_MODEL})`);
134
+ else warn(`STT server at ${c.STT_URL} answered HTTP ${r.status}; QA will be skipped per beat`);
135
+ } catch (err) {
136
+ warn(`STT server at ${c.STT_URL} unreachable (${err.message})`);
137
+ }
138
+ } else ok("narration QA off", "set RIKROK_STT_URL to enable transcribe-back checks");
139
+
140
+ const chrome = process.env.RIKROK_BROWSER || findChrome();
141
+ if (chrome) ok("render browser (headless Chrome)", path.basename(path.dirname(chrome)));
142
+ else warn("no headless Chrome yet: Remotion downloads one on the first render (a few hundred MB, one-off)", "run `rikrok demo` now to get that out of the way, or set RIKROK_BROWSER=/path/to/chrome");
143
+
144
+ if (await portFree(c.FEED_PORT, c.FEED_BIND)) ok(`feed port ${c.FEED_BIND}:${c.FEED_PORT} free`);
145
+ else warn(`feed port ${c.FEED_BIND}:${c.FEED_PORT} is in use`, "set RIKROK_PORT, or this is your own feed already running");
146
+
147
+ fs.mkdirSync(c.RIKROK_HOME, { recursive: true });
148
+ ok(`data dir ${c.RIKROK_HOME}`);
149
+
150
+ console.log(failures ? `\n${failures} problem(s) to fix.` : "\nAll good. Try: rikrok backfill --limit 2 && rikrok feed");
151
+ return failures ? 1 : 0;
152
+ }
@@ -0,0 +1,7 @@
1
+ import { startFeed } from "../../server/feed.mjs";
2
+ export async function run(args) {
3
+ const port = args.port ? Number(args.port) : undefined;
4
+ const bind = typeof args.bind === "string" ? args.bind : undefined;
5
+ await startFeed({ port, bind });
6
+ await new Promise(() => {});
7
+ }
@@ -0,0 +1,77 @@
1
+ // rikrok hook install | uninstall | run
2
+ // A Claude Code SessionEnd hook that recaps the session you just left. `run` reads the hook's
3
+ // JSON from stdin, starts `rikrok recap` detached, and returns within the hook's time budget.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { BIN, LOG_DIR, RIKROK_HOME, ensureDirs } from "../lib/config.mjs";
9
+
10
+ const SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
11
+ const isOurs = (entry) => Array.isArray(entry?.hooks) && entry.hooks.some((h) => /rikrok(\.mjs)?"? hook run/.test(String(h.command || "")));
12
+
13
+ function readSettings() {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(SETTINGS, "utf-8"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function install() {
22
+ const s = readSettings();
23
+ s.hooks = s.hooks || {};
24
+ const list = (s.hooks.SessionEnd = s.hooks.SessionEnd || []);
25
+ if (list.some(isOurs)) {
26
+ console.log("already installed");
27
+ return 0;
28
+ }
29
+ const command = `RIKROK_HOME=${JSON.stringify(RIKROK_HOME)} ${JSON.stringify(process.execPath)} ${JSON.stringify(BIN)} hook run`;
30
+ list.push({ matcher: "clear|logout|prompt_input_exit|other", hooks: [{ type: "command", command, timeout: 5 }] });
31
+ fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
32
+ fs.writeFileSync(SETTINGS, JSON.stringify(s, null, 2) + "\n");
33
+ console.log(`installed a SessionEnd hook in ${SETTINGS}\nWhen you leave a Claude Code session with real work in it, a reel is built in the background.`);
34
+ return 0;
35
+ }
36
+
37
+ export function uninstall() {
38
+ const s = readSettings();
39
+ const list = s.hooks?.SessionEnd;
40
+ if (!Array.isArray(list)) return 0;
41
+ const kept = list.filter((e) => !isOurs(e));
42
+ if (kept.length) s.hooks.SessionEnd = kept;
43
+ else delete s.hooks.SessionEnd;
44
+ if (!Object.keys(s.hooks).length) delete s.hooks;
45
+ fs.writeFileSync(SETTINGS, JSON.stringify(s, null, 2) + "\n");
46
+ console.log("removed");
47
+ return 0;
48
+ }
49
+
50
+ export async function runHook() {
51
+ if (process.env.RIKROK_HOOK_SKIP) return 0; // a session Rik Rok itself started (script writing)
52
+ let input = "";
53
+ for await (const chunk of process.stdin) input += chunk;
54
+ let j = {};
55
+ try {
56
+ j = JSON.parse(input);
57
+ } catch {}
58
+ if (!j.transcript_path || j.reason === "resume") return 0;
59
+ ensureDirs();
60
+ const out = fs.openSync(path.join(LOG_DIR, "recap.log"), "a");
61
+ const child = spawn(process.execPath, [BIN, "recap", "--transcript", j.transcript_path, "--session", j.session_id || ""], {
62
+ detached: true,
63
+ stdio: ["ignore", out, out],
64
+ env: { ...process.env, RIKROK_HOOK_SKIP: "" },
65
+ });
66
+ child.unref();
67
+ return 0;
68
+ }
69
+
70
+ export async function run(args) {
71
+ const sub = args._[0];
72
+ if (sub === "install") return install();
73
+ if (sub === "uninstall") return uninstall();
74
+ if (sub === "run") return runHook();
75
+ console.log("usage: rikrok hook install | uninstall | run");
76
+ return 1;
77
+ }
@@ -0,0 +1,66 @@
1
+ // macOS: install launchd agents so the watcher and feed run at login.
2
+ // RIKROK_* variables in your environment at install time are baked into the plists.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ import { PKG_ROOT, BIN, LOG_DIR, RIKROK_HOME, ensureDirs, effectiveEnv } from "../lib/config.mjs";
8
+
9
+ const AGENTS = { watch: "com.rikrok.watch", feed: "com.rikrok.feed" };
10
+ const VOICE_AGENT = "com.rikrok.voice";
11
+ const dir = path.join(os.homedir(), "Library", "LaunchAgents");
12
+ const uid = process.getuid?.() ?? 501;
13
+
14
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
+
16
+ function bootout(label) {
17
+ try {
18
+ execFileSync("launchctl", ["bootout", `gui/${uid}/${label}`], { stdio: "ignore" });
19
+ } catch {}
20
+ }
21
+
22
+ export async function run(args) {
23
+ if (process.platform !== "darwin") {
24
+ console.log(`No launchd here. Run the two processes under your service manager, for example systemd user units:\n`);
25
+ for (const cmd of ["watch", "feed"]) {
26
+ console.log(`# ~/.config/systemd/user/rikrok-${cmd}.service\n[Unit]\nDescription=Rik Rok ${cmd}\n[Service]\nExecStart=${process.execPath} ${BIN} ${cmd}\nRestart=always\nEnvironment=RIKROK_HOME=${RIKROK_HOME}\n[Install]\nWantedBy=default.target\n`);
27
+ }
28
+ console.log("then: systemctl --user daemon-reload && systemctl --user enable --now rikrok-watch rikrok-feed");
29
+ return 0;
30
+ }
31
+ if (args.uninstall) {
32
+ for (const label of [...Object.values(AGENTS), VOICE_AGENT]) {
33
+ bootout(label);
34
+ fs.rmSync(path.join(dir, `${label}.plist`), { force: true });
35
+ console.log(`removed ${label}`);
36
+ }
37
+ return 0;
38
+ }
39
+ ensureDirs();
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ const tmpl = fs.readFileSync(path.join(PKG_ROOT, "launchd", "com.rikrok.plist.tmpl"), "utf-8");
42
+ const envVars = { RIKROK_HOME, ...effectiveEnv() };
43
+ const envXml = Object.entries(envVars).map(([k, v]) => ` <key>${esc(k)}</key><string>${esc(v)}</string>`).join("\n");
44
+ const PATH = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", path.dirname(process.execPath)].filter((p, i, a) => a.indexOf(p) === i).join(":");
45
+ const agents = { ...AGENTS };
46
+ if (process.env.RIKROK_CLONE_API === "voice-clone") agents["voice serve"] = VOICE_AGENT;
47
+ for (const [cmd, label] of Object.entries(agents)) {
48
+ const plist = tmpl
49
+ .replaceAll("{{LABEL}}", label)
50
+ .replaceAll("{{NODE}}", esc(process.execPath))
51
+ .replaceAll("{{BIN}}", esc(BIN))
52
+ .replaceAll("<string>{{CMD}}</string>", cmd.split(" ").map((c) => `<string>${c}</string>`).join("\n "))
53
+ .replaceAll("{{WORKDIR}}", esc(PKG_ROOT))
54
+ .replaceAll("{{LOG}}", esc(path.join(LOG_DIR, `${cmd.replace(" ", "-")}.log`)))
55
+ .replaceAll("{{ERRLOG}}", esc(path.join(LOG_DIR, `${cmd.replace(" ", "-")}.err.log`)))
56
+ .replaceAll("{{PATH}}", esc(PATH))
57
+ .replaceAll("{{ENV}}", envXml);
58
+ const file = path.join(dir, `${label}.plist`);
59
+ bootout(label);
60
+ fs.writeFileSync(file, plist);
61
+ execFileSync("launchctl", ["bootstrap", `gui/${uid}`, file]);
62
+ console.log(`installed ${label} -> ${file}`);
63
+ }
64
+ console.log(`\nlogs: ${LOG_DIR}\nrestart: launchctl kickstart -k gui/${uid}/${AGENTS.watch}\nremove: rikrok install --uninstall`);
65
+ return 0;
66
+ }
@@ -0,0 +1,66 @@
1
+ // rikrok recap --transcript <path> [--session <id>] [--cwd <dir>] [--force]
2
+ // Recap one session now (what the SessionEnd hook calls). Covers only the lines since the
3
+ // last recap of that session, respects the same "real work" bar as the watcher, and takes a
4
+ // lock so two recaps never render at once.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { RIKROK_HOME, ensureDirs } from "../lib/config.mjs";
8
+ import * as claude from "../sources/claude.mjs";
9
+ import { loadState, saveState, sessionKey } from "../lib/state.mjs";
10
+ import { buildReel } from "../lib/pipeline.mjs";
11
+
12
+ const LOCK = () => path.join(RIKROK_HOME, "recap.lock");
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+
15
+ async function withLock(fn) {
16
+ const lock = LOCK();
17
+ for (let i = 0; i < 120; i++) {
18
+ try {
19
+ const st = fs.statSync(lock);
20
+ if (Date.now() - st.mtimeMs > 30 * 60_000) fs.rmSync(lock, { force: true }); // stale
21
+ else {
22
+ await sleep(15_000);
23
+ continue;
24
+ }
25
+ } catch {}
26
+ try {
27
+ fs.writeFileSync(lock, String(process.pid), { flag: "wx" });
28
+ break;
29
+ } catch {}
30
+ }
31
+ try {
32
+ return await fn();
33
+ } finally {
34
+ fs.rmSync(lock, { force: true });
35
+ }
36
+ }
37
+
38
+ export async function run(args) {
39
+ ensureDirs();
40
+ const transcript = typeof args.transcript === "string" ? path.resolve(args.transcript) : null;
41
+ if (!transcript || !fs.existsSync(transcript)) {
42
+ console.error("usage: rikrok recap --transcript <session.jsonl> [--session <id>] [--force]");
43
+ return 1;
44
+ }
45
+ const sessionId = typeof args.session === "string" ? args.session : path.basename(transcript, ".jsonl");
46
+ const f = { source: "claude", path: transcript, sessionId, projectDir: path.basename(path.dirname(transcript)), mtimeMs: fs.statSync(transcript).mtimeMs };
47
+ return withLock(async () => {
48
+ const state = loadState();
49
+ const key = sessionKey(f);
50
+ const st = state.sessions[key];
51
+ const fromLine = args.force ? 0 : st?.lastLine || 0;
52
+ const act = await claude.parseSession(transcript, fromLine);
53
+ if (!args.force && !claude.qualifies(act)) {
54
+ console.log(`[recap] ${sessionId.slice(0, 8)}: not enough new work since line ${fromLine} (${act.assistantTurns} turns, ${act.toolUses} tool calls); skipped`);
55
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: fromLine, skipped: true };
56
+ saveState(state);
57
+ return 0;
58
+ }
59
+ console.log(`[recap] ${sessionId.slice(0, 8)}: lines ${fromLine}..${act.totalLines}`);
60
+ const { totalLines, outPath } = await buildReel(f, fromLine);
61
+ state.sessions[key] = { lastMtimeMs: f.mtimeMs, lastLine: totalLines, lastRecapAt: new Date().toISOString() };
62
+ saveState(state);
63
+ console.log(`[recap] done: ${outPath}`);
64
+ return 0;
65
+ });
66
+ }
@@ -0,0 +1,162 @@
1
+ // rikrok setup: the guided path. Finds a local LLM (or installs Ollama), records your
2
+ // voice, writes ~/.rikrok/config.json, renders the demo, and offers to start the feed.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import readline from "node:readline/promises";
7
+ import { execFileSync, spawnSync } from "node:child_process";
8
+ import * as c from "../lib/config.mjs";
9
+ import { setup as voiceSetup, saveConfig } from "./voice.mjs";
10
+
11
+ const has = (bin) => spawnSync("which", [bin]).status === 0;
12
+
13
+ // oMLX keeps its API key in ~/.omlx/settings.json; the wizard copies it so nothing else needs typing.
14
+ function omlxKey() {
15
+ try {
16
+ const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".omlx", "settings.json"), "utf-8"));
17
+ return j.auth?.api_key || "";
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ async function models(url) {
24
+ try {
25
+ const key = /:(8800|8000)$/.test(url) ? omlxKey() : "";
26
+ const r = await fetch(`${url}/v1/models`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(4000) });
27
+ if (!r.ok) return null;
28
+ const j = await r.json();
29
+ return (j.data || []).map((m) => m.id);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function run(args) {
36
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
37
+ const ask = async (q, d) => {
38
+ const a = (await rl.question(`${q}${d !== undefined ? ` [${d}]` : ""} `)).trim();
39
+ return a === "" ? d : a;
40
+ };
41
+ const yes = async (q, d = "y") => /^y/i.test(await ask(`${q} (y/n)`, d));
42
+ const cfg = {};
43
+ try {
44
+ console.log(`\nRik Rok setup. Everything stays on this machine. Ctrl-C any time.\n`);
45
+
46
+ // 1. Who writes the script
47
+ const claudeHere = has("claude");
48
+ let useClaude = false;
49
+ if (claudeHere) {
50
+ console.log("Scripts. Rik Rok can ask Claude Code to write each recap (your subscription, no local model),\nor use a local model server (Ollama, LM Studio, oMLX).");
51
+ useClaude = await yes("Use Claude Code to write the recaps? (easiest)");
52
+ }
53
+ if (useClaude) {
54
+ cfg.RIKROK_SCRIPT = "claude";
55
+ saveConfig(cfg);
56
+ if (await yes("Recap a session automatically when you leave it? (adds a SessionEnd hook to ~/.claude/settings.json)")) {
57
+ const { install } = await import("./hook.mjs");
58
+ install();
59
+ }
60
+ }
61
+ const candidates = useClaude ? [] : [
62
+ ["Ollama", "http://127.0.0.1:11434"],
63
+ ["LM Studio", "http://127.0.0.1:1234"],
64
+ ["oMLX", "http://127.0.0.1:8800"],
65
+ ["oMLX", "http://127.0.0.1:8000"],
66
+ ["current setting", c.LLM_URL],
67
+ ];
68
+ let llmUrl = null, list = null, name = null;
69
+ for (const [n, url] of candidates) {
70
+ const m = await models(url);
71
+ if (m) {
72
+ llmUrl = url;
73
+ list = m;
74
+ name = n;
75
+ break;
76
+ }
77
+ }
78
+ if (!llmUrl && !useClaude) {
79
+ console.log("No local LLM server found (looked for Ollama, LM Studio, oMLX).");
80
+ if (process.platform === "darwin" && has("brew") && (await yes("Install Ollama with Homebrew and start it?"))) {
81
+ execFileSync("brew", ["install", "ollama"], { stdio: "inherit" });
82
+ spawnSync("brew", ["services", "start", "ollama"], { stdio: "inherit" });
83
+ await new Promise((r) => setTimeout(r, 3000));
84
+ list = await models("http://127.0.0.1:11434");
85
+ if (list) {
86
+ llmUrl = "http://127.0.0.1:11434";
87
+ name = "Ollama";
88
+ }
89
+ }
90
+ }
91
+ if (llmUrl) {
92
+ console.log(`Found ${name} at ${llmUrl}${list.length ? ` with ${list.length} model(s)` : ""}.`);
93
+ let model = c.LLM_MODEL && list.includes(c.LLM_MODEL) ? c.LLM_MODEL : list[0] || "";
94
+ if (name === "Ollama" && !list.length) {
95
+ if (await yes("No models yet. Pull qwen3:8b (about 5 GB)?")) {
96
+ execFileSync("ollama", ["pull", "qwen3:8b"], { stdio: "inherit" });
97
+ model = "qwen3:8b";
98
+ }
99
+ } else if (list.length > 1) {
100
+ list.slice(0, 15).forEach((m, i) => console.log(` ${i + 1} ${m}`));
101
+ const pick = await ask("Which model for scripts? (number or name)", model);
102
+ model = /^\d+$/.test(pick) ? list[Number(pick) - 1] || model : pick;
103
+ }
104
+ cfg.RIKROK_LLM_URL = llmUrl;
105
+ cfg.RIKROK_LLM_MODEL = model;
106
+ if (name === "Ollama") cfg.RIKROK_LLM_EXTRA = { think: false };
107
+ if (name === "oMLX") cfg.RIKROK_LLM_EXTRA = { chat_template_kwargs: { enable_thinking: false } };
108
+ if (name === "oMLX") {
109
+ // One server for everything: scripts, your voice (Qwen3-TTS Base) and narration QA (whisper).
110
+ cfg.RIKROK_TTS_URL = llmUrl;
111
+ cfg.RIKROK_STT_URL = llmUrl;
112
+ const key = omlxKey();
113
+ if (key) cfg.RIKROK_LLM_KEY = key;
114
+ const tts = list.find((m) => /Qwen3-TTS.*Base/i.test(m));
115
+ const stt = list.find((m) => /whisper/i.test(m));
116
+ if (tts) cfg.RIKROK_CLONE_MODEL = tts;
117
+ else console.log(" oMLX has no Qwen3-TTS Base model loaded yet. Add mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16 in oMLX, then `rikrok voice setup`.");
118
+ if (stt) cfg.RIKROK_STT_MODEL = stt;
119
+ else delete cfg.RIKROK_STT_URL;
120
+ }
121
+ } else if (!useClaude) {
122
+ console.log("Skipping the LLM for now: reels will use the template script until you set RIKROK_LLM_MODEL.");
123
+ }
124
+ if (!useClaude) cfg.RIKROK_SCRIPT = "local";
125
+ saveConfig(cfg);
126
+
127
+ // 2. Voice
128
+ console.log("\nVoice. Reels can be narrated in your own voice from a 20-second recording.");
129
+ if (await yes("Record your voice now?")) {
130
+ await voiceSetup({}, rl);
131
+ if (!cfg.RIKROK_TTS_URL && (await yes("No cloning server yet. Install a local one now? (rikrok voice serve, about 4.5 GB download)"))) {
132
+ console.log("Run `rikrok voice serve` in another terminal and leave it running; `rikrok install` keeps it running at login.");
133
+ }
134
+ } else if (process.platform === "darwin") {
135
+ saveConfig({ RIKROK_VOICE: "say:Samantha" });
136
+ console.log("Using the built-in macOS voice for now. `rikrok voice setup` whenever you like.");
137
+ } else {
138
+ saveConfig({ RIKROK_VOICE: "none" });
139
+ console.log("Silent reels for now (captions carry the story). `rikrok voice setup` when you have a speech server.");
140
+ }
141
+
142
+ // 3. Check and demo
143
+ console.log("\nChecking everything...");
144
+ spawnSync(process.execPath, [c.BIN, "doctor"], { stdio: "inherit" });
145
+ if (await yes("Render the demo reel now? (first run downloads a headless Chrome)")) {
146
+ spawnSync(process.execPath, [c.BIN, "demo"], { stdio: "inherit" });
147
+ }
148
+ if (await yes("Recap your 3 most recent sessions now?")) {
149
+ spawnSync(process.execPath, [c.BIN, "backfill", "--limit", "3"], { stdio: "inherit" });
150
+ }
151
+ if (process.platform === "darwin" && (await yes("Keep the watcher and feed running at login?"))) {
152
+ spawnSync(process.execPath, [c.BIN, "install"], { stdio: "inherit" });
153
+ console.log(`\nFeed: http://127.0.0.1:${c.FEED_PORT} (see README "On your phone" to reach it from your phone)`);
154
+ } else {
155
+ console.log(`\nStart the feed any time: rikrok feed (then http://127.0.0.1:${c.FEED_PORT})\nWatch for new sessions: rikrok watch`);
156
+ }
157
+ console.log(`\nSettings saved to ${c.CONFIG_FILE}.`);
158
+ return 0;
159
+ } finally {
160
+ rl.close();
161
+ }
162
+ }