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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/assets/icon.svg +1 -0
- package/assets/readme/beat-flow.jpg +0 -0
- package/assets/readme/beat-headline.jpg +0 -0
- package/assets/readme/beat-next.jpg +0 -0
- package/assets/readme/beat-play.jpg +0 -0
- package/assets/readme/beat-status.jpg +0 -0
- package/assets/readme/beats.jpg +0 -0
- package/assets/wordmark.png +0 -0
- package/assets/wordmark.svg +1 -0
- package/bin/rikrok.mjs +63 -0
- package/launchd/com.rikrok.plist.tmpl +25 -0
- package/package.json +70 -0
- package/remotion/Reel.tsx +513 -0
- package/remotion/Root.tsx +71 -0
- package/remotion/index.ts +4 -0
- package/remotion/public/silence.wav +0 -0
- package/remotion/theme.ts +52 -0
- package/scripts/gen-icon.mjs +117 -0
- package/scripts/ui-check.mjs +76 -0
- package/server/feed.mjs +153 -0
- package/server/public/app.js +342 -0
- package/server/public/icon-180.png +0 -0
- package/server/public/icon-512.png +0 -0
- package/server/public/icon.svg +1 -0
- package/server/public/index.html +112 -0
- package/server/public/manifest.webmanifest +14 -0
- package/server/public/sw.js +22 -0
- package/src/cli/backfill.mjs +13 -0
- package/src/cli/config.mjs +29 -0
- package/src/cli/demo.mjs +14 -0
- package/src/cli/doctor.mjs +152 -0
- package/src/cli/feed.mjs +7 -0
- package/src/cli/hook.mjs +77 -0
- package/src/cli/install.mjs +66 -0
- package/src/cli/recap.mjs +66 -0
- package/src/cli/setup.mjs +162 -0
- package/src/cli/voice.mjs +214 -0
- package/src/cli/watch.mjs +5 -0
- package/src/hooks/comment.mjs +21 -0
- package/src/lib/backfill.mjs +40 -0
- package/src/lib/config.mjs +89 -0
- package/src/lib/evidence.mjs +21 -0
- package/src/lib/gitinfo.mjs +40 -0
- package/src/lib/llm.mjs +258 -0
- package/src/lib/narrate.mjs +148 -0
- package/src/lib/palette.mjs +27 -0
- package/src/lib/paths.mjs +29 -0
- package/src/lib/pipeline.mjs +180 -0
- package/src/lib/render-job.mjs +76 -0
- package/src/lib/script-claude.mjs +48 -0
- package/src/lib/state.mjs +19 -0
- package/src/lib/stt.mjs +26 -0
- package/src/lib/watcher.mjs +102 -0
- package/src/sources/claude.mjs +168 -0
- package/src/sources/index.mjs +26 -0
- package/src/voices/clone.mjs +66 -0
- package/src/voices/fx.mjs +22 -0
- package/src/voices/index.mjs +46 -0
- package/src/voices/module.mjs +18 -0
- package/src/voices/none.mjs +23 -0
- package/src/voices/openai-speech.mjs +34 -0
- package/src/voices/say.mjs +32 -0
- package/test/fixtures/claude-session.jsonl +23 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// rikrok voice setup | test | devices
|
|
2
|
+
// Records a short reference clip in your voice, saves it with its transcript, checks it
|
|
3
|
+
// against your speech server, and switches RIKROK_VOICE to clone. No training, no upload.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import readline from "node:readline/promises";
|
|
7
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
|
+
import { RIKROK_HOME, VOICE_DIR, CLONE_REF, CLONE_TEXT, CONFIG_FILE, TTS_URL, VOICE_SERVER_DIR, VOICE_SERVER_PORT, ensureDirs } from "../lib/config.mjs";
|
|
9
|
+
import { cloneVoice } from "../voices/clone.mjs";
|
|
10
|
+
|
|
11
|
+
// Short, neutral, covers most English sounds, easy to read in one breath per line.
|
|
12
|
+
export const SCRIPT = [
|
|
13
|
+
"Here is the recap for today. The signup form is fixed, all four tests pass, and the change is live.",
|
|
14
|
+
"Three things shipped, two are still open, and the next step is to check it on a phone before the demo.",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function listDevices() {
|
|
18
|
+
if (process.platform !== "darwin") return [];
|
|
19
|
+
const out = spawnSync("ffmpeg", ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { encoding: "utf-8" });
|
|
20
|
+
const text = (out.stderr || "") + (out.stdout || "");
|
|
21
|
+
const audio = text.split("audio devices:")[1] || "";
|
|
22
|
+
return [...audio.matchAll(/\[(\d+)\]\s+(.+)$/gm)].map((m) => ({ index: Number(m[1]), name: m[2].trim() }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function record(outPath, seconds, device) {
|
|
26
|
+
const args = process.platform === "darwin"
|
|
27
|
+
? ["-y", "-f", "avfoundation", "-i", `:${device}`, "-t", String(seconds), "-ar", "24000", "-ac", "1", outPath]
|
|
28
|
+
: ["-y", "-f", "pulse", "-i", "default", "-t", String(seconds), "-ar", "24000", "-ac", "1", outPath];
|
|
29
|
+
execFileSync("ffmpeg", args, { stdio: ["ignore", "ignore", "inherit"] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function trimSilence(p) {
|
|
33
|
+
const out = p.replace(/\.wav$/, "_trim.wav");
|
|
34
|
+
execFileSync("ffmpeg", ["-y", "-i", p, "-af", "silenceremove=start_periods=1:start_threshold=-40dB:start_silence=0.2,areverse,silenceremove=start_periods=1:start_threshold=-40dB:start_silence=0.3,areverse,loudnorm=I=-20:TP=-1.5:LRA=9", "-ar", "24000", "-ac", "1", out], { stdio: "ignore" });
|
|
35
|
+
fs.renameSync(out, p);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function play(p) {
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform === "darwin") execFileSync("afplay", [p]);
|
|
41
|
+
else execFileSync("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", p]);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveConfig(patch) {
|
|
46
|
+
let cfg = {};
|
|
47
|
+
try {
|
|
48
|
+
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
49
|
+
} catch {}
|
|
50
|
+
Object.assign(cfg, patch);
|
|
51
|
+
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
|
52
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
53
|
+
try {
|
|
54
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function setup(args, rl) {
|
|
59
|
+
ensureDirs();
|
|
60
|
+
fs.mkdirSync(VOICE_DIR, { recursive: true });
|
|
61
|
+
const own = rl || readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
62
|
+
const ask = async (q, d) => {
|
|
63
|
+
const a = (await own.question(`${q}${d !== undefined ? ` [${d}]` : ""} `)).trim();
|
|
64
|
+
return a === "" ? d : a;
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
if (args.file) {
|
|
68
|
+
// Bring your own clip: 10 to 25 seconds of you talking, plus what you said.
|
|
69
|
+
const src = path.resolve(String(args.file));
|
|
70
|
+
if (!fs.existsSync(src)) throw new Error(`no such file: ${src}`);
|
|
71
|
+
execFileSync("ffmpeg", ["-y", "-i", src, "-ar", "24000", "-ac", "1", CLONE_REF], { stdio: "ignore" });
|
|
72
|
+
let text = typeof args.text === "string" ? args.text : "";
|
|
73
|
+
if (!text && typeof args["text-file"] === "string") text = fs.readFileSync(args["text-file"], "utf-8");
|
|
74
|
+
if (!text) text = await ask("Type exactly what is said in the clip:");
|
|
75
|
+
fs.writeFileSync(CLONE_TEXT, text.trim() + "\n");
|
|
76
|
+
} else {
|
|
77
|
+
const devices = listDevices();
|
|
78
|
+
let device = args.device !== undefined ? Number(args.device) : 0;
|
|
79
|
+
if (devices.length > 1 && args.device === undefined) {
|
|
80
|
+
console.log("\nMicrophones:");
|
|
81
|
+
devices.forEach((d) => console.log(` ${d.index} ${d.name}`));
|
|
82
|
+
device = Number(await ask("Which one?", devices[0].index));
|
|
83
|
+
}
|
|
84
|
+
const seconds = Number(args.seconds || 22);
|
|
85
|
+
console.log(`\nRead this in your normal voice. Recording starts when you press Enter and runs ${seconds} seconds.\n`);
|
|
86
|
+
for (const line of SCRIPT) console.log(` ${line}`);
|
|
87
|
+
console.log();
|
|
88
|
+
await ask("Press Enter to start recording");
|
|
89
|
+
console.log("Recording...");
|
|
90
|
+
record(CLONE_REF, seconds, device);
|
|
91
|
+
trimSilence(CLONE_REF);
|
|
92
|
+
fs.writeFileSync(CLONE_TEXT, SCRIPT.join(" ") + "\n");
|
|
93
|
+
console.log("Done. Playing it back.");
|
|
94
|
+
play(CLONE_REF);
|
|
95
|
+
const again = await ask("Keep it? (y = keep, n = record again)", "y");
|
|
96
|
+
if (/^n/i.test(again)) return setup({ ...args, device }, own);
|
|
97
|
+
}
|
|
98
|
+
// Try it against the speech server.
|
|
99
|
+
const v = cloneVoice();
|
|
100
|
+
const a = await v.available();
|
|
101
|
+
if (!a.ok) {
|
|
102
|
+
console.log(`\nSaved the clip and transcript in ${VOICE_DIR}.\nCould not reach a cloning server: ${a.reason}\nPoint RIKROK_TTS_URL at one (oMLX with Qwen3-TTS works) and run \`rikrok voice test\`.`);
|
|
103
|
+
saveConfig({ RIKROK_VOICE: "clone" });
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const testPath = path.join(VOICE_DIR, "test.wav");
|
|
107
|
+
console.log(`\nAsking ${TTS_URL} to say a line in your voice...`);
|
|
108
|
+
const r = await v.synth("This is your recap, in your own voice. Nothing left the machine.", testPath);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
console.log(`The server did not like it: ${r.error}\nThe clip is saved; fix the server and run \`rikrok voice test\`.`);
|
|
111
|
+
saveConfig({ RIKROK_VOICE: "clone" });
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
play(testPath);
|
|
115
|
+
saveConfig({ RIKROK_VOICE: "clone" });
|
|
116
|
+
console.log(`\nYour voice is set. RIKROK_VOICE=clone is saved in ${CONFIG_FILE}.\nNext: rikrok demo`);
|
|
117
|
+
return 0;
|
|
118
|
+
} finally {
|
|
119
|
+
if (!rl) own.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
// ---- rikrok voice serve: a local cloning server, installed on first use ----
|
|
125
|
+
// Runs the Qwen3-TTS OpenAI-compatible FastAPI server (Apache-2.0) in its own uv-managed
|
|
126
|
+
// Python environment under ~/.rikrok/voice-server. MLX on Apple Silicon, PyTorch elsewhere.
|
|
127
|
+
// The model (about 4.5 GB for the 1.7B Base) downloads from Hugging Face on first start.
|
|
128
|
+
const SERVER_REPO = "https://github.com/groxaxo/Qwen3-TTS-Openai-Fastapi.git";
|
|
129
|
+
const APPLE = process.platform === "darwin" && process.arch === "arm64";
|
|
130
|
+
const MODELS = {
|
|
131
|
+
mlx: { full: "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", fast: "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16" },
|
|
132
|
+
torch: { full: "Qwen/Qwen3-TTS-12Hz-1.7B-Base", fast: "Qwen/Qwen3-TTS-12Hz-0.6B-Base" },
|
|
133
|
+
};
|
|
134
|
+
const srcDir = () => path.join(VOICE_SERVER_DIR, "src");
|
|
135
|
+
const venvPy = () => path.join(srcDir(), ".venv", "bin", process.platform === "win32" ? "python.exe" : "python");
|
|
136
|
+
|
|
137
|
+
function haveUv() {
|
|
138
|
+
return spawnSync("uv", ["--version"], { stdio: "ignore" }).status === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function installServer({ log = console.log } = {}) {
|
|
142
|
+
if (!haveUv()) {
|
|
143
|
+
log("This needs uv (the Python tool manager). Install it with:\n curl -LsSf https://astral.sh/uv/install.sh | sh\nthen run `rikrok voice serve` again.");
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(VOICE_SERVER_DIR, { recursive: true });
|
|
147
|
+
if (!fs.existsSync(path.join(srcDir(), "pyproject.toml"))) {
|
|
148
|
+
log(`Fetching the speech server into ${srcDir()} ...`);
|
|
149
|
+
execFileSync("git", ["clone", "--depth", "1", SERVER_REPO, srcDir()], { stdio: "inherit" });
|
|
150
|
+
}
|
|
151
|
+
if (!fs.existsSync(venvPy())) {
|
|
152
|
+
log("Creating its Python environment (uv, Python 3.12) ...");
|
|
153
|
+
execFileSync("uv", ["venv", "--python", "3.12", path.join(srcDir(), ".venv")], { stdio: "inherit", cwd: srcDir() });
|
|
154
|
+
const extras = APPLE ? ".[api,mlx]" : ".[api]";
|
|
155
|
+
log(`Installing the server${APPLE ? " with MLX" : ""} (a few minutes the first time) ...`);
|
|
156
|
+
execFileSync("uv", ["pip", "install", "--python", venvPy(), "-e", extras], { stdio: "inherit", cwd: srcDir() });
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function serve(args) {
|
|
162
|
+
if (!installServer()) return 1;
|
|
163
|
+
const fast = Boolean(args.fast);
|
|
164
|
+
const backend = APPLE ? "mlx" : "pytorch";
|
|
165
|
+
const model = APPLE ? MODELS.mlx[fast ? "fast" : "full"] : MODELS.torch[fast ? "fast" : "full"];
|
|
166
|
+
const port = Number(args.port || VOICE_SERVER_PORT);
|
|
167
|
+
const lib = path.join(VOICE_DIR, "library");
|
|
168
|
+
fs.mkdirSync(lib, { recursive: true });
|
|
169
|
+
const env = { ...process.env, PORT: String(port), HOST: "127.0.0.1", TTS_BACKEND: backend, VOICE_LIBRARY_DIR: lib };
|
|
170
|
+
if (APPLE) env.MLX_MODEL_ID = model;
|
|
171
|
+
else {
|
|
172
|
+
env.TTS_MODEL_NAME = model;
|
|
173
|
+
if (!process.env.TTS_DEVICE) env.TTS_DEVICE = "cpu";
|
|
174
|
+
}
|
|
175
|
+
// Point Rik Rok at it, and at the dedicated clone endpoint.
|
|
176
|
+
saveConfig({ RIKROK_TTS_URL: `http://127.0.0.1:${port}`, RIKROK_CLONE_API: "voice-clone", RIKROK_VOICE: "clone" });
|
|
177
|
+
console.log(`Speech server: ${backend} backend, model ${model}, http://127.0.0.1:${port}\nFirst start downloads the model from Hugging Face (about ${fast ? "1.5" : "4.5"} GB). Ctrl-C to stop.\n`);
|
|
178
|
+
const child = spawnSync(venvPy(), ["-m", "api.main"], { cwd: srcDir(), env, stdio: "inherit" });
|
|
179
|
+
return child.status ?? 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function test() {
|
|
183
|
+
const v = cloneVoice();
|
|
184
|
+
const a = await v.available();
|
|
185
|
+
if (!a.ok) {
|
|
186
|
+
console.log(`clone voice not ready: ${a.reason}`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
const testPath = path.join(VOICE_DIR, "test.wav");
|
|
190
|
+
const r = await v.synth("This is your recap, in your own voice. Nothing left the machine.", testPath);
|
|
191
|
+
if (!r.ok) {
|
|
192
|
+
console.log(`synthesis failed: ${r.error}`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
console.log(`ok, wrote ${testPath}`);
|
|
196
|
+
play(testPath);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function run(args) {
|
|
201
|
+
const sub = args._[0] || "help";
|
|
202
|
+
if (sub === "setup") return setup(args);
|
|
203
|
+
if (sub === "test") return test();
|
|
204
|
+
if (sub === "serve") return serve(args);
|
|
205
|
+
if (sub === "install-server") return installServer() ? 0 : 1;
|
|
206
|
+
if (sub === "devices") {
|
|
207
|
+
const d = listDevices();
|
|
208
|
+
if (!d.length) console.log(process.platform === "darwin" ? "no input devices found" : "device listing is macOS only; recording uses the default pulse input");
|
|
209
|
+
d.forEach((x) => console.log(`${x.index} ${x.name}`));
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
console.log("usage: rikrok voice setup [--file clip.wav --text \"what is said\"] [--device N] [--seconds 22] | test | devices | serve [--fast] [--port N] | install-server");
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Comment routing. A comment on a reel is always saved to its sidecar. If
|
|
2
|
+
// RIKROK_COMMENT_HOOK is set, that shell command also runs with a JSON payload on
|
|
3
|
+
// stdin, so a reply to a recap can become an instruction for the project's agent
|
|
4
|
+
// (an issue tracker, a message queue, `claude --resume`, whatever you wire up).
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { COMMENT_HOOK } from "../lib/config.mjs";
|
|
7
|
+
|
|
8
|
+
export const hookConfigured = () => Boolean(COMMENT_HOOK);
|
|
9
|
+
|
|
10
|
+
export function routeComment(payload) {
|
|
11
|
+
if (!COMMENT_HOOK) return null;
|
|
12
|
+
try {
|
|
13
|
+
const proc = spawn("sh", ["-c", COMMENT_HOOK], { stdio: ["pipe", "inherit", "inherit"], timeout: 10_000 });
|
|
14
|
+
proc.on("error", (err) => console.error(`[feed] comment hook failed: ${err.message}`));
|
|
15
|
+
proc.on("close", (code) => code !== 0 && console.error(`[feed] comment hook exited ${code}`));
|
|
16
|
+
proc.stdin.end(JSON.stringify(payload));
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error(`[feed] comment hook failed: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
return COMMENT_HOOK;
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Backfill: reels for the most recently modified qualifying sessions, generated
|
|
2
|
+
// oldest to newest so the feed fills up in order.
|
|
3
|
+
import { sources } from "../sources/index.mjs";
|
|
4
|
+
import { saveState, sessionKey } from "./state.mjs";
|
|
5
|
+
import { buildReel } from "./pipeline.mjs";
|
|
6
|
+
|
|
7
|
+
export async function runBackfill(state, { limit = 10 } = {}) {
|
|
8
|
+
const picked = [];
|
|
9
|
+
for (const src of sources()) {
|
|
10
|
+
const files = src.listSessions().sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
11
|
+
let n = 0;
|
|
12
|
+
for (const f of files) {
|
|
13
|
+
if (n >= limit) break;
|
|
14
|
+
if (state.sessions[sessionKey(f)]?.lastRecapAt) continue;
|
|
15
|
+
let act;
|
|
16
|
+
try {
|
|
17
|
+
act = await src.parseSession(f.path, 0);
|
|
18
|
+
} catch {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (src.qualifies(act)) {
|
|
22
|
+
picked.push(f);
|
|
23
|
+
n++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
picked.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
28
|
+
console.log(`[backfill] ${picked.length} qualifying session(s)`);
|
|
29
|
+
for (const f of picked) {
|
|
30
|
+
try {
|
|
31
|
+
console.log(`[backfill] ${f.source}:${f.projectDir}/${f.sessionId.slice(0, 8)}`);
|
|
32
|
+
const { totalLines } = await buildReel(f, 0);
|
|
33
|
+
state.sessions[sessionKey(f)] = { lastMtimeMs: f.mtimeMs, lastLine: totalLines, lastRecapAt: new Date().toISOString() };
|
|
34
|
+
saveState(state);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[backfill] failed ${f.sessionId}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return picked.length;
|
|
40
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Runtime settings. Precedence: environment > ~/.rikrok/config.json > defaults.
|
|
2
|
+
// Every knob is a RIKROK_* variable; see README "Configuration".
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { CONFIG_FILE, RIKROK_HOME, expandHome } from "./paths.mjs";
|
|
7
|
+
export * from "./paths.mjs";
|
|
8
|
+
|
|
9
|
+
// config.json keys (RIKROK_*) fill in anything the environment did not set.
|
|
10
|
+
try {
|
|
11
|
+
const j = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
12
|
+
for (const [k, v] of Object.entries(j)) {
|
|
13
|
+
if (!/^RIKROK_/.test(k) || process.env[k] !== undefined || v == null) continue;
|
|
14
|
+
process.env[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
15
|
+
}
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
const env = (k, d) => (process.env[k] !== undefined && process.env[k] !== "" ? process.env[k] : d);
|
|
19
|
+
const num = (k, d) => {
|
|
20
|
+
const n = Number(env(k, d));
|
|
21
|
+
return Number.isFinite(n) ? n : d;
|
|
22
|
+
};
|
|
23
|
+
const base = (u) => String(u).replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
24
|
+
|
|
25
|
+
// Sessions
|
|
26
|
+
export const CLAUDE_PROJECTS = path.resolve(expandHome(env("RIKROK_CLAUDE_DIR", path.join(os.homedir(), ".claude", "projects"))));
|
|
27
|
+
export const SOURCES = env("RIKROK_SOURCES", "claude").split(",").map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
export const IDLE_MINUTES = num("RIKROK_IDLE_MINUTES", 15);
|
|
29
|
+
export const MIN_ASSISTANT_TURNS = num("RIKROK_MIN_TURNS", 10);
|
|
30
|
+
export const MIN_TOOL_USES = num("RIKROK_MIN_TOOLS", 3);
|
|
31
|
+
export const MAX_REELS_PER_HOUR = num("RIKROK_MAX_PER_HOUR", 4);
|
|
32
|
+
export const PROJECT_NAME_RE = env("RIKROK_PROJECT_NAME_RE", "");
|
|
33
|
+
|
|
34
|
+
// Feed
|
|
35
|
+
export const FEED_PORT = num("RIKROK_PORT", 4870);
|
|
36
|
+
export const FEED_BIND = env("RIKROK_BIND", "127.0.0.1");
|
|
37
|
+
export const COMMENT_HOOK = env("RIKROK_COMMENT_HOOK", "");
|
|
38
|
+
export const HANDLE = env("RIKROK_HANDLE", "");
|
|
39
|
+
export const FLOW_BEAT = env("RIKROK_FLOW", "on") !== "off";
|
|
40
|
+
|
|
41
|
+
// Who writes the script: "claude" (headless `claude -p`, your subscription), "local" (an
|
|
42
|
+
// OpenAI-compatible server), or "auto" (claude when no local model is configured and the CLI exists)
|
|
43
|
+
export const SCRIPT_BACKEND = env("RIKROK_SCRIPT", "auto");
|
|
44
|
+
export const CLAUDE_BIN = env("RIKROK_CLAUDE_BIN", "claude");
|
|
45
|
+
export const CLAUDE_MODEL = env("RIKROK_CLAUDE_MODEL", "sonnet");
|
|
46
|
+
|
|
47
|
+
// Script LLM: any OpenAI-compatible chat endpoint (Ollama, LM Studio, oMLX, ...)
|
|
48
|
+
export const LLM_URL = base(env("RIKROK_LLM_URL", "http://127.0.0.1:11434"));
|
|
49
|
+
export const LLM_MODEL = env("RIKROK_LLM_MODEL", "");
|
|
50
|
+
export const LLM_KEY = env("RIKROK_LLM_KEY", "");
|
|
51
|
+
export const LLM_EXTRA = (() => {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(env("RIKROK_LLM_EXTRA", "{}"));
|
|
54
|
+
} catch {
|
|
55
|
+
console.error("[config] RIKROK_LLM_EXTRA is not valid JSON, ignoring it");
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
// Voice: say:<Voice> | openai-speech:<voice> | module:<path> | none
|
|
61
|
+
export const VOICE = env("RIKROK_VOICE", process.platform === "darwin" ? "say:Samantha" : "none");
|
|
62
|
+
export const VOICE_FX = env("RIKROK_VOICE_FX", "none");
|
|
63
|
+
export const TTS_URL = base(env("RIKROK_TTS_URL", LLM_URL));
|
|
64
|
+
export const TTS_MODEL = env("RIKROK_TTS_MODEL", "tts-1");
|
|
65
|
+
export const TTS_KEY = env("RIKROK_TTS_KEY", LLM_KEY);
|
|
66
|
+
// Own voice (RIKROK_VOICE=clone): reference clip + transcript, recorded by `rikrok voice setup`
|
|
67
|
+
export const VOICE_DIR = path.join(RIKROK_HOME, "voice");
|
|
68
|
+
export const CLONE_REF = path.resolve(expandHome(env("RIKROK_CLONE_REF", path.join(VOICE_DIR, "ref.wav"))));
|
|
69
|
+
export const CLONE_TEXT = path.resolve(expandHome(env("RIKROK_CLONE_TEXT", path.join(VOICE_DIR, "ref.txt"))));
|
|
70
|
+
export const CLONE_MODEL = env("RIKROK_CLONE_MODEL", "Qwen3-TTS-12Hz-1.7B-Base-bf16");
|
|
71
|
+
// How the speech server takes the reference clip: "speech" = ref_audio on /v1/audio/speech (oMLX),
|
|
72
|
+
// "voice-clone" = the dedicated /v1/audio/voice-clone endpoint (the server `rikrok voice serve` runs)
|
|
73
|
+
export const CLONE_API = env("RIKROK_CLONE_API", "speech");
|
|
74
|
+
export const VOICE_SERVER_DIR = path.join(RIKROK_HOME, "voice-server");
|
|
75
|
+
export const VOICE_SERVER_PORT = num("RIKROK_VOICE_PORT", 4873);
|
|
76
|
+
|
|
77
|
+
// Optional transcribe-back QA of narration (off unless RIKROK_STT_URL is set)
|
|
78
|
+
export const STT_URL = env("RIKROK_STT_URL", "") ? base(env("RIKROK_STT_URL")) : "";
|
|
79
|
+
export const STT_MODEL = env("RIKROK_STT_MODEL", "whisper-1");
|
|
80
|
+
export const STT_KEY = env("RIKROK_STT_KEY", LLM_KEY);
|
|
81
|
+
|
|
82
|
+
export function authHeaders(key) {
|
|
83
|
+
return key ? { Authorization: `Bearer ${key}` } : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Every RIKROK_* variable currently in effect (for doctor and install).
|
|
87
|
+
export function effectiveEnv() {
|
|
88
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith("RIKROK_")).sort());
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Condensed, prompt-sized evidence for the script LLM. Works on the Activity
|
|
2
|
+
// shape every session source produces (see src/sources/index.mjs).
|
|
3
|
+
export function condense(act, gitCommits = []) {
|
|
4
|
+
const fmt = (arr, n, f) => arr.slice(-n).map(f);
|
|
5
|
+
const toolTally = {};
|
|
6
|
+
for (const t of act.tools) toolTally[t.name] = (toolTally[t.name] || 0) + 1;
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
duration_minutes: Math.max(1, Math.round(act.activeMs / 60000)),
|
|
10
|
+
assistant_turns: act.assistantTurns,
|
|
11
|
+
tool_uses: act.toolUses,
|
|
12
|
+
user_prompts: fmt(act.userPrompts, 6, (p) => p.text),
|
|
13
|
+
assistant_notes: fmt(act.assistantTexts, 8, (a) => a.text.slice(0, 350)),
|
|
14
|
+
files_touched: act.filesTouched.slice(0, 15),
|
|
15
|
+
commands_run: fmt(act.commands, 10, (c) => c.desc || c.command),
|
|
16
|
+
tool_tally: toolTally,
|
|
17
|
+
git_commits: gitCommits.slice(0, 12),
|
|
18
|
+
urls_mentioned: act.urls.slice(0, 8),
|
|
19
|
+
todos: (act.todoSnapshots || []).map((t) => ({ content: t.content, status: t.status })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
// Commits in the project repo within the session window. Read-only.
|
|
5
|
+
export function gitCommitsFor(cwd, sinceTs, untilTs) {
|
|
6
|
+
if (!cwd || !fs.existsSync(cwd)) return [];
|
|
7
|
+
try {
|
|
8
|
+
const args = [
|
|
9
|
+
"-C", cwd, "log", "--oneline", "--no-decorate",
|
|
10
|
+
`--since=${new Date(sinceTs - 60000).toISOString()}`,
|
|
11
|
+
];
|
|
12
|
+
if (untilTs) args.push(`--until=${new Date(untilTs + 300000).toISOString()}`);
|
|
13
|
+
const out = execFileSync("git", args, { encoding: "utf-8", timeout: 10000 });
|
|
14
|
+
return out.split("\n").filter(Boolean).slice(0, 20);
|
|
15
|
+
} catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function gitDiffStat(cwd, sinceTs) {
|
|
21
|
+
if (!cwd || !fs.existsSync(cwd)) return null;
|
|
22
|
+
try {
|
|
23
|
+
const range = execFileSync(
|
|
24
|
+
"git",
|
|
25
|
+
["-C", cwd, "log", "--format=%H", `--since=${new Date(sinceTs - 60000).toISOString()}`],
|
|
26
|
+
{ encoding: "utf-8", timeout: 10000 },
|
|
27
|
+
)
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
if (range.length === 0) return null;
|
|
31
|
+
const out = execFileSync(
|
|
32
|
+
"git",
|
|
33
|
+
["-C", cwd, "diff", "--shortstat", `${range[range.length - 1]}~1..${range[0]}`],
|
|
34
|
+
{ encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
|
|
35
|
+
).trim();
|
|
36
|
+
return out || null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|