@tensorkit/raydr 0.0.0-stage → 0.1.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/README.md +553 -2
- package/dist/chunk-KYTKA3YV.js +217 -0
- package/dist/chunk-NRB77EMM.js +6 -0
- package/dist/hook.js +95 -0
- package/dist/index.js +25 -0
- package/dist/main.js +2745 -0
- package/dist/version-IVSD7EGG.js +6 -0
- package/local/raydr_local.py +244 -0
- package/package.json +36 -4
- package/web/assets/index-9v58_QZ6.css +2 -0
- package/web/assets/index-CA1TzOHc.js +8 -0
- package/web/index.html +20 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,2745 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PORT,
|
|
3
|
+
SPEECH_PROVIDERS,
|
|
4
|
+
TRANSCRIPTION_PROVIDERS,
|
|
5
|
+
defaultEnvFile,
|
|
6
|
+
disableSession,
|
|
7
|
+
enableSession,
|
|
8
|
+
isEnabled,
|
|
9
|
+
loadConfig,
|
|
10
|
+
migrateEnvFile,
|
|
11
|
+
newAccessKey,
|
|
12
|
+
parseEnvFile,
|
|
13
|
+
validSessionId
|
|
14
|
+
} from "./chunk-KYTKA3YV.js";
|
|
15
|
+
import {
|
|
16
|
+
VERSION
|
|
17
|
+
} from "./chunk-NRB77EMM.js";
|
|
18
|
+
|
|
19
|
+
// src/main.ts
|
|
20
|
+
import { spawn as spawn3, spawnSync as spawnSync2 } from "node:child_process";
|
|
21
|
+
import {
|
|
22
|
+
copyFileSync,
|
|
23
|
+
existsSync as existsSync10,
|
|
24
|
+
mkdirSync as mkdirSync7,
|
|
25
|
+
readFileSync as readFileSync8,
|
|
26
|
+
renameSync as renameSync3,
|
|
27
|
+
writeFileSync as writeFileSync6
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { homedir as homedir3 } from "node:os";
|
|
30
|
+
import { dirname as dirname6, join as join10, resolve as resolve3 } from "node:path";
|
|
31
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
32
|
+
|
|
33
|
+
// ../../apps/server/dist/providers.js
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
// ../../apps/server/dist/speech-elevenlabs.js
|
|
38
|
+
import { Readable } from "node:stream";
|
|
39
|
+
var ElevenLabsSpeech = class {
|
|
40
|
+
name = "elevenlabs";
|
|
41
|
+
opts;
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
this.opts = { modelId: "eleven_flash_v2_5", ...opts };
|
|
44
|
+
}
|
|
45
|
+
async synthesize(text) {
|
|
46
|
+
const url = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(this.opts.voiceId)}/stream?output_format=mp3_44100_128`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "xi-api-key": this.opts.apiKey, "content-type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ text, model_id: this.opts.modelId }),
|
|
51
|
+
signal: AbortSignal.timeout(3e4)
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok || !res.body) {
|
|
54
|
+
const detail = await res.text().catch(() => "");
|
|
55
|
+
throw new Error(`ElevenLabs ${res.status}: ${detail.slice(0, 200)}`);
|
|
56
|
+
}
|
|
57
|
+
return Readable.fromWeb(res.body);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ../../apps/server/dist/speech-local.js
|
|
62
|
+
import { Readable as Readable2 } from "node:stream";
|
|
63
|
+
var KokoroSpeech = class {
|
|
64
|
+
name = "kokoro";
|
|
65
|
+
baseUrl;
|
|
66
|
+
voice;
|
|
67
|
+
key;
|
|
68
|
+
constructor(port, voice, key, host = "127.0.0.1") {
|
|
69
|
+
this.baseUrl = `http://${host}:${port}`;
|
|
70
|
+
this.voice = voice;
|
|
71
|
+
this.key = key;
|
|
72
|
+
}
|
|
73
|
+
async synthesize(text) {
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(`${this.baseUrl}/tts`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json", "x-raydr-key": this.key },
|
|
79
|
+
body: JSON.stringify({ text, voice: this.voice }),
|
|
80
|
+
signal: AbortSignal.timeout(12e4)
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`local sidecar unreachable at ${this.baseUrl}: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok || !res.body) {
|
|
86
|
+
const detail = await res.text().catch(() => "");
|
|
87
|
+
throw new Error(`Kokoro ${res.status}: ${detail.slice(0, 200)}`);
|
|
88
|
+
}
|
|
89
|
+
return Readable2.fromWeb(res.body);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ../../apps/server/dist/speech-openai.js
|
|
94
|
+
import { Readable as Readable3 } from "node:stream";
|
|
95
|
+
var OpenAISpeech = class {
|
|
96
|
+
name = "openai";
|
|
97
|
+
opts;
|
|
98
|
+
constructor(opts) {
|
|
99
|
+
this.opts = { model: "gpt-4o-mini-tts", baseUrl: "https://api.openai.com", ...opts };
|
|
100
|
+
}
|
|
101
|
+
async synthesize(text) {
|
|
102
|
+
const res = await fetch(`${this.opts.baseUrl}/v1/audio/speech`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { authorization: `Bearer ${this.opts.apiKey}`, "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
model: this.opts.model,
|
|
107
|
+
voice: this.opts.voice,
|
|
108
|
+
input: text,
|
|
109
|
+
// Only the instructable models accept a delivery instruction.
|
|
110
|
+
...this.opts.model.includes("mini-tts") ? { instructions: this.opts.instructions } : {},
|
|
111
|
+
response_format: "mp3"
|
|
112
|
+
}),
|
|
113
|
+
signal: AbortSignal.timeout(3e4)
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok || !res.body) {
|
|
116
|
+
const detail = await res.text().catch(() => "");
|
|
117
|
+
throw new Error(`OpenAI ${res.status}: ${detail.slice(0, 200)}`);
|
|
118
|
+
}
|
|
119
|
+
return Readable3.fromWeb(res.body);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ../../apps/server/dist/transcription.js
|
|
124
|
+
var TranscriptionError = class extends Error {
|
|
125
|
+
status;
|
|
126
|
+
constructor(status, message) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.status = status;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ../../apps/server/dist/transcription-elevenlabs.js
|
|
133
|
+
var ElevenLabsTranscription = class {
|
|
134
|
+
name = "elevenlabs";
|
|
135
|
+
apiKey;
|
|
136
|
+
constructor(apiKey) {
|
|
137
|
+
this.apiKey = apiKey;
|
|
138
|
+
}
|
|
139
|
+
async transcribe(audio, filename) {
|
|
140
|
+
const form = new FormData();
|
|
141
|
+
form.set("model_id", "scribe_v2");
|
|
142
|
+
form.set("file", audio, filename);
|
|
143
|
+
const res = await fetch("https://api.elevenlabs.io/v1/speech-to-text", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "xi-api-key": this.apiKey },
|
|
146
|
+
body: form,
|
|
147
|
+
signal: AbortSignal.timeout(3e4)
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const detail = await res.text().catch(() => "");
|
|
151
|
+
throw new TranscriptionError(res.status, `ElevenLabs STT ${res.status}: ${detail.slice(0, 200)}`);
|
|
152
|
+
}
|
|
153
|
+
const body = await res.json();
|
|
154
|
+
return typeof body.text === "string" ? body.text : "";
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ../../apps/server/dist/transcription-local.js
|
|
159
|
+
var WhisperTranscription = class {
|
|
160
|
+
name = "whisper";
|
|
161
|
+
baseUrl;
|
|
162
|
+
key;
|
|
163
|
+
constructor(port, key, host = "127.0.0.1") {
|
|
164
|
+
this.baseUrl = `http://${host}:${port}`;
|
|
165
|
+
this.key = key;
|
|
166
|
+
}
|
|
167
|
+
async transcribe(audio, filename) {
|
|
168
|
+
const form = new FormData();
|
|
169
|
+
form.set("file", audio, filename);
|
|
170
|
+
let res;
|
|
171
|
+
try {
|
|
172
|
+
res = await fetch(`${this.baseUrl}/stt`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "x-raydr-key": this.key },
|
|
175
|
+
body: form,
|
|
176
|
+
signal: AbortSignal.timeout(12e4)
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw new TranscriptionError(503, `local sidecar unreachable: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const detail = await res.text().catch(() => "");
|
|
183
|
+
throw new TranscriptionError(res.status, `whisper ${res.status}: ${detail.slice(0, 200)}`);
|
|
184
|
+
}
|
|
185
|
+
const body = await res.json();
|
|
186
|
+
return typeof body.text === "string" ? body.text : "";
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ../../apps/server/dist/transcription-openai.js
|
|
191
|
+
var OpenAITranscription = class {
|
|
192
|
+
name = "openai";
|
|
193
|
+
opts;
|
|
194
|
+
constructor(opts) {
|
|
195
|
+
this.opts = { model: "gpt-4o-mini-transcribe", baseUrl: "https://api.openai.com", ...opts };
|
|
196
|
+
}
|
|
197
|
+
async transcribe(audio, filename) {
|
|
198
|
+
const form = new FormData();
|
|
199
|
+
form.set("model", this.opts.model);
|
|
200
|
+
form.set("response_format", "json");
|
|
201
|
+
form.set("file", audio, filename);
|
|
202
|
+
const res = await fetch(`${this.opts.baseUrl}/v1/audio/transcriptions`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { authorization: `Bearer ${this.opts.apiKey}` },
|
|
205
|
+
body: form,
|
|
206
|
+
signal: AbortSignal.timeout(3e4)
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
const detail = await res.text().catch(() => "");
|
|
210
|
+
throw new TranscriptionError(res.status, `OpenAI STT ${res.status}: ${detail.slice(0, 200)}`);
|
|
211
|
+
}
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
return typeof body.text === "string" ? body.text : "";
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// ../../apps/server/dist/providers.js
|
|
218
|
+
function localPython(cfg) {
|
|
219
|
+
return join(cfg.localDir, "venv", "bin", "python");
|
|
220
|
+
}
|
|
221
|
+
function localSetupMarker(cfg) {
|
|
222
|
+
return join(cfg.localDir, "setup.json");
|
|
223
|
+
}
|
|
224
|
+
function localReady(cfg) {
|
|
225
|
+
if (!existsSync(localPython(cfg)))
|
|
226
|
+
return false;
|
|
227
|
+
try {
|
|
228
|
+
const rec = JSON.parse(readFileSync(localSetupMarker(cfg), "utf8"));
|
|
229
|
+
return rec.whisperModel === cfg.whisperModel && rec.kokoroVoice === cfg.kokoroVoice;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function speechReadiness(cfg) {
|
|
235
|
+
const provider = cfg.speechProvider;
|
|
236
|
+
switch (provider) {
|
|
237
|
+
case "elevenlabs":
|
|
238
|
+
return cfg.elevenLabsApiKey ? { provider, ready: true } : { provider, ready: false, missing: "ELEVENLABS_API_KEY", fix: "raydr init" };
|
|
239
|
+
case "openai":
|
|
240
|
+
return cfg.openAiApiKey ? { provider, ready: true } : { provider, ready: false, missing: "OPENAI_API_KEY", fix: "raydr init" };
|
|
241
|
+
case "kokoro":
|
|
242
|
+
return localReady(cfg) ? { provider, ready: true } : { provider, ready: false, missing: "local models", fix: "raydr local setup" };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function transcriptionReadiness(cfg) {
|
|
246
|
+
const provider = cfg.transcriptionProvider;
|
|
247
|
+
switch (provider) {
|
|
248
|
+
case "elevenlabs":
|
|
249
|
+
return cfg.elevenLabsApiKey ? { provider, ready: true } : { provider, ready: false, missing: "ELEVENLABS_API_KEY", fix: "raydr init" };
|
|
250
|
+
case "openai":
|
|
251
|
+
return cfg.openAiApiKey ? { provider, ready: true } : { provider, ready: false, missing: "OPENAI_API_KEY", fix: "raydr init" };
|
|
252
|
+
case "whisper":
|
|
253
|
+
return localReady(cfg) ? { provider, ready: true } : { provider, ready: false, missing: "local models", fix: "raydr local setup" };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function usesLocal(cfg) {
|
|
257
|
+
return cfg.speechProvider === "kokoro" || cfg.transcriptionProvider === "whisper";
|
|
258
|
+
}
|
|
259
|
+
function selectSpeechProvider(cfg) {
|
|
260
|
+
if (!speechReadiness(cfg).ready)
|
|
261
|
+
return null;
|
|
262
|
+
switch (cfg.speechProvider) {
|
|
263
|
+
case "elevenlabs":
|
|
264
|
+
return new ElevenLabsSpeech({
|
|
265
|
+
apiKey: cfg.elevenLabsApiKey ?? "",
|
|
266
|
+
voiceId: cfg.elevenLabsVoiceId
|
|
267
|
+
});
|
|
268
|
+
case "openai":
|
|
269
|
+
return new OpenAISpeech({
|
|
270
|
+
apiKey: cfg.openAiApiKey ?? "",
|
|
271
|
+
voice: cfg.openAiVoice,
|
|
272
|
+
instructions: cfg.speechInstructions
|
|
273
|
+
});
|
|
274
|
+
case "kokoro":
|
|
275
|
+
return new KokoroSpeech(cfg.localPort, cfg.kokoroVoice, cfg.accessKey);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function selectTranscriptionProvider(cfg) {
|
|
279
|
+
if (!transcriptionReadiness(cfg).ready)
|
|
280
|
+
return null;
|
|
281
|
+
switch (cfg.transcriptionProvider) {
|
|
282
|
+
case "elevenlabs":
|
|
283
|
+
return new ElevenLabsTranscription(cfg.elevenLabsApiKey ?? "");
|
|
284
|
+
case "openai":
|
|
285
|
+
return new OpenAITranscription({ apiKey: cfg.openAiApiKey ?? "" });
|
|
286
|
+
case "whisper":
|
|
287
|
+
return new WhisperTranscription(cfg.localPort, cfg.accessKey);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ../../apps/server/dist/server.js
|
|
292
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
293
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
294
|
+
import { createServer } from "node:http";
|
|
295
|
+
import { dirname as dirname2, join as join5, resolve } from "node:path";
|
|
296
|
+
import { PassThrough as PassThrough2 } from "node:stream";
|
|
297
|
+
import { fileURLToPath } from "node:url";
|
|
298
|
+
|
|
299
|
+
// ../../apps/server/dist/audio-store.js
|
|
300
|
+
import { EventEmitter, once } from "node:events";
|
|
301
|
+
import { createReadStream, createWriteStream, existsSync as existsSync2, mkdirSync, statSync } from "node:fs";
|
|
302
|
+
import { readdir, rename, stat, unlink } from "node:fs/promises";
|
|
303
|
+
import { join as join2 } from "node:path";
|
|
304
|
+
import { PassThrough } from "node:stream";
|
|
305
|
+
var AudioStore = class {
|
|
306
|
+
jobs = /* @__PURE__ */ new Map();
|
|
307
|
+
dir;
|
|
308
|
+
constructor(dir) {
|
|
309
|
+
this.dir = dir;
|
|
310
|
+
mkdirSync(dir, { recursive: true });
|
|
311
|
+
}
|
|
312
|
+
pathFor(id) {
|
|
313
|
+
return join2(this.dir, `${id}.mp3`);
|
|
314
|
+
}
|
|
315
|
+
/** True when a complete clip is on disk. */
|
|
316
|
+
has(id) {
|
|
317
|
+
return existsSync2(this.pathFor(id));
|
|
318
|
+
}
|
|
319
|
+
partPathFor(id) {
|
|
320
|
+
return `${this.pathFor(id)}.part`;
|
|
321
|
+
}
|
|
322
|
+
/** Persist a stream under `id`. Resolves when the file is fully written. */
|
|
323
|
+
async write(id, source) {
|
|
324
|
+
const job = { chunks: [], done: false, failed: false, events: new EventEmitter() };
|
|
325
|
+
job.events.setMaxListeners(0);
|
|
326
|
+
this.jobs.set(id, job);
|
|
327
|
+
const part = this.partPathFor(id);
|
|
328
|
+
const file = createWriteStream(part);
|
|
329
|
+
const opened = once(file, "open");
|
|
330
|
+
opened.catch(() => {
|
|
331
|
+
});
|
|
332
|
+
let fileError = null;
|
|
333
|
+
file.on("error", (err) => {
|
|
334
|
+
fileError = err;
|
|
335
|
+
});
|
|
336
|
+
let ready = false;
|
|
337
|
+
try {
|
|
338
|
+
for await (const chunk of source) {
|
|
339
|
+
if (!ready) {
|
|
340
|
+
await opened;
|
|
341
|
+
ready = true;
|
|
342
|
+
}
|
|
343
|
+
if (fileError)
|
|
344
|
+
throw fileError;
|
|
345
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
346
|
+
job.chunks.push(buf);
|
|
347
|
+
file.write(buf);
|
|
348
|
+
job.events.emit("chunk", buf);
|
|
349
|
+
}
|
|
350
|
+
if (!ready)
|
|
351
|
+
await opened;
|
|
352
|
+
if (fileError)
|
|
353
|
+
throw fileError;
|
|
354
|
+
await new Promise((resolve4, reject) => file.end((err) => err ? reject(err) : resolve4()));
|
|
355
|
+
await rename(part, this.pathFor(id));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
job.failed = true;
|
|
358
|
+
if (!file.closed) {
|
|
359
|
+
file.destroy();
|
|
360
|
+
await once(file, "close");
|
|
361
|
+
}
|
|
362
|
+
await unlink(part).catch(() => {
|
|
363
|
+
});
|
|
364
|
+
throw err;
|
|
365
|
+
} finally {
|
|
366
|
+
job.done = true;
|
|
367
|
+
job.events.emit("done");
|
|
368
|
+
this.jobs.delete(id);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Open a readable for `id`. Returns the total size when the clip is complete on disk,
|
|
373
|
+
* null while it is still streaming, or undefined when unknown. Partial files are never served.
|
|
374
|
+
*/
|
|
375
|
+
open(id) {
|
|
376
|
+
const job = this.jobs.get(id);
|
|
377
|
+
if (job && !job.done) {
|
|
378
|
+
const out = new PassThrough();
|
|
379
|
+
for (const c of job.chunks)
|
|
380
|
+
out.write(c);
|
|
381
|
+
const onChunk = (c) => out.write(c);
|
|
382
|
+
const onDone = () => {
|
|
383
|
+
job.events.off("chunk", onChunk);
|
|
384
|
+
if (job.failed)
|
|
385
|
+
out.destroy(new Error("speech synthesis failed"));
|
|
386
|
+
else
|
|
387
|
+
out.end();
|
|
388
|
+
};
|
|
389
|
+
job.events.on("chunk", onChunk);
|
|
390
|
+
job.events.once("done", onDone);
|
|
391
|
+
out.once("close", () => {
|
|
392
|
+
job.events.off("chunk", onChunk);
|
|
393
|
+
job.events.off("done", onDone);
|
|
394
|
+
});
|
|
395
|
+
return { stream: out, size: null };
|
|
396
|
+
}
|
|
397
|
+
const path = this.pathFor(id);
|
|
398
|
+
if (!existsSync2(path))
|
|
399
|
+
return void 0;
|
|
400
|
+
return { stream: createReadStream(path), size: statSync(path).size };
|
|
401
|
+
}
|
|
402
|
+
/** Delete clips (complete or partial) older than `maxAgeMs`. Other files are left alone. */
|
|
403
|
+
async prune(maxAgeMs) {
|
|
404
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
405
|
+
let removed = 0;
|
|
406
|
+
for (const name of await readdir(this.dir)) {
|
|
407
|
+
if (!name.endsWith(".mp3") && !name.endsWith(".mp3.part"))
|
|
408
|
+
continue;
|
|
409
|
+
const path = join2(this.dir, name);
|
|
410
|
+
const info = await stat(path).catch(() => null);
|
|
411
|
+
if (info?.isFile() && info.mtimeMs < cutoff) {
|
|
412
|
+
await unlink(path).catch(() => {
|
|
413
|
+
});
|
|
414
|
+
removed++;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return removed;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// ../../apps/server/dist/herdr.js
|
|
422
|
+
import { execFile } from "node:child_process";
|
|
423
|
+
var CommandError = class extends Error {
|
|
424
|
+
code;
|
|
425
|
+
constructor(code, message = code) {
|
|
426
|
+
super(message);
|
|
427
|
+
this.code = code;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
function herdr(args) {
|
|
431
|
+
return new Promise((resolve4, reject) => {
|
|
432
|
+
execFile("herdr", args, { timeout: 15e3 }, (err, stdout, stderr) => {
|
|
433
|
+
if (err)
|
|
434
|
+
reject(new Error(`herdr ${args.slice(0, 2).join(" ")} failed: ${stderr || err.message}`));
|
|
435
|
+
else
|
|
436
|
+
resolve4(stdout);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
var herdrCli = {
|
|
441
|
+
async status(pane) {
|
|
442
|
+
const out = JSON.parse(await herdr(["agent", "get", pane]));
|
|
443
|
+
return out.result?.agent?.agent_status ?? "unknown";
|
|
444
|
+
},
|
|
445
|
+
async sendKeys(pane, keys) {
|
|
446
|
+
await herdr(["agent", "send-keys", pane, ...keys]);
|
|
447
|
+
},
|
|
448
|
+
async prompt(pane, text) {
|
|
449
|
+
await herdr(["agent", "prompt", pane, text]);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
453
|
+
function keysForQuestion(question, rawSelection) {
|
|
454
|
+
const count = question.options.length;
|
|
455
|
+
const selection = [...new Set(rawSelection)];
|
|
456
|
+
if (selection.length === 0)
|
|
457
|
+
throw new CommandError("other_not_supported", "free-text answers are not supported yet");
|
|
458
|
+
if (selection.length > count)
|
|
459
|
+
throw new CommandError("bad_selection", `at most ${count} option(s) can be chosen`);
|
|
460
|
+
for (const idx of selection) {
|
|
461
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > count) {
|
|
462
|
+
throw new CommandError("bad_selection", `option ${idx} is out of range 1..${count}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!question.multiSelect) {
|
|
466
|
+
if (selection.length !== 1)
|
|
467
|
+
throw new CommandError("bad_selection", "single-select question takes one option");
|
|
468
|
+
return [String(selection[0])];
|
|
469
|
+
}
|
|
470
|
+
const keys = [];
|
|
471
|
+
let cursor = 1;
|
|
472
|
+
for (const idx of [...selection].sort((a, b) => a - b)) {
|
|
473
|
+
for (; cursor < idx; cursor++)
|
|
474
|
+
keys.push("down");
|
|
475
|
+
keys.push("space");
|
|
476
|
+
}
|
|
477
|
+
keys.push("right");
|
|
478
|
+
return keys;
|
|
479
|
+
}
|
|
480
|
+
async function answerQuestion(term, pane, questions, selections) {
|
|
481
|
+
if (selections.length !== questions.length) {
|
|
482
|
+
throw new CommandError("bad_selection", `expected ${questions.length} selection(s), got ${selections.length}`);
|
|
483
|
+
}
|
|
484
|
+
const plan = questions.map((q, i) => keysForQuestion(q, selections[i] ?? []));
|
|
485
|
+
for (let i = 0; i < plan.length; i++) {
|
|
486
|
+
if (i > 0)
|
|
487
|
+
await sleep(300);
|
|
488
|
+
await term.sendKeys(pane, plan[i] ?? []);
|
|
489
|
+
}
|
|
490
|
+
if (questions.some((q) => q.multiSelect)) {
|
|
491
|
+
await sleep(300);
|
|
492
|
+
await term.sendKeys(pane, ["enter"]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function replyPermission(term, pane, decision) {
|
|
496
|
+
await term.sendKeys(pane, [decision === "allow" ? "1" : "esc"]);
|
|
497
|
+
}
|
|
498
|
+
async function say(term, pane, text) {
|
|
499
|
+
const status = await term.status(pane);
|
|
500
|
+
if (status === "blocked")
|
|
501
|
+
throw new CommandError("agent_blocked", "a dialog is open; answer it first");
|
|
502
|
+
await term.prompt(pane, text);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ../../apps/server/dist/commands.js
|
|
506
|
+
var isObject = (v) => typeof v === "object" && v !== null;
|
|
507
|
+
var str = (v) => typeof v === "string" ? v : "";
|
|
508
|
+
function parseCommand(body) {
|
|
509
|
+
if (!isObject(body))
|
|
510
|
+
return null;
|
|
511
|
+
const commandId = str(body.commandId);
|
|
512
|
+
const sessionId = str(body.sessionId);
|
|
513
|
+
if (!commandId || !sessionId)
|
|
514
|
+
return null;
|
|
515
|
+
switch (body.type) {
|
|
516
|
+
case "answer_question": {
|
|
517
|
+
const toolUseId = str(body.toolUseId);
|
|
518
|
+
const selections = body.selections;
|
|
519
|
+
if (!toolUseId || !Array.isArray(selections) || !selections.every((s) => Array.isArray(s) && s.every((n) => typeof n === "number"))) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
type: "answer_question",
|
|
524
|
+
commandId,
|
|
525
|
+
sessionId,
|
|
526
|
+
toolUseId,
|
|
527
|
+
selections
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
case "permission_reply":
|
|
531
|
+
if (body.decision !== "allow" && body.decision !== "deny")
|
|
532
|
+
return null;
|
|
533
|
+
return { type: "permission_reply", commandId, sessionId, decision: body.decision };
|
|
534
|
+
case "say": {
|
|
535
|
+
const text = str(body.text).trim();
|
|
536
|
+
if (!text)
|
|
537
|
+
return null;
|
|
538
|
+
return { type: "say", commandId, sessionId, text };
|
|
539
|
+
}
|
|
540
|
+
default:
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
var PERMISSION_TTL_MS = 5 * 60 * 1e3;
|
|
545
|
+
var CommandRunner = class {
|
|
546
|
+
pending = /* @__PURE__ */ new Map();
|
|
547
|
+
/** Session id -> when the prompt was reported; entries older than PERMISSION_TTL_MS are ignored. */
|
|
548
|
+
pendingPermission = /* @__PURE__ */ new Map();
|
|
549
|
+
now;
|
|
550
|
+
term;
|
|
551
|
+
sessions;
|
|
552
|
+
constructor(term, sessions, now = Date.now) {
|
|
553
|
+
this.term = term;
|
|
554
|
+
this.sessions = sessions;
|
|
555
|
+
this.now = now;
|
|
556
|
+
}
|
|
557
|
+
/** Remember the menu currently open in a session so answers can be validated against it. */
|
|
558
|
+
setPending(sessionId, question) {
|
|
559
|
+
this.pending.set(sessionId, question);
|
|
560
|
+
}
|
|
561
|
+
/** A permission dialog is open in the session (Claude Code told us via Notification). */
|
|
562
|
+
setPendingPermission(sessionId) {
|
|
563
|
+
this.pendingPermission.set(sessionId, this.now());
|
|
564
|
+
}
|
|
565
|
+
/** The turn moved on: no menu or dialog is answerable any more. */
|
|
566
|
+
clearPending(sessionId) {
|
|
567
|
+
this.pending.delete(sessionId);
|
|
568
|
+
this.pendingPermission.delete(sessionId);
|
|
569
|
+
}
|
|
570
|
+
/** Throws CommandError with a short code on any failure. */
|
|
571
|
+
async run(cmd) {
|
|
572
|
+
const session2 = this.sessions.get(cmd.sessionId);
|
|
573
|
+
if (!session2)
|
|
574
|
+
throw new CommandError("unknown_session");
|
|
575
|
+
if (!session2.paneId)
|
|
576
|
+
throw new CommandError("no_pane", "session is not running in a Herdr pane");
|
|
577
|
+
const pane = session2.paneId;
|
|
578
|
+
switch (cmd.type) {
|
|
579
|
+
case "answer_question": {
|
|
580
|
+
const open2 = this.pending.get(cmd.sessionId);
|
|
581
|
+
if (!open2 || open2.toolUseId !== cmd.toolUseId)
|
|
582
|
+
throw new CommandError("unknown_question", "that question is no longer open");
|
|
583
|
+
await answerQuestion(this.term, pane, open2.questions, cmd.selections);
|
|
584
|
+
this.pending.delete(cmd.sessionId);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
case "permission_reply": {
|
|
588
|
+
const since = this.pendingPermission.get(cmd.sessionId);
|
|
589
|
+
if (since === void 0 || this.now() - since > PERMISSION_TTL_MS) {
|
|
590
|
+
this.pendingPermission.delete(cmd.sessionId);
|
|
591
|
+
throw new CommandError("no_pending_permission", "no permission prompt is open");
|
|
592
|
+
}
|
|
593
|
+
if (await this.term.status(pane) !== "blocked") {
|
|
594
|
+
throw new CommandError("agent_not_blocked", "the session is not waiting on a dialog");
|
|
595
|
+
}
|
|
596
|
+
await replyPermission(this.term, pane, cmd.decision);
|
|
597
|
+
this.pendingPermission.delete(cmd.sessionId);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
case "say":
|
|
601
|
+
await say(this.term, pane, cmd.text);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// ../../apps/server/dist/event-bus.js
|
|
608
|
+
var EventBus = class {
|
|
609
|
+
nextId = Date.now() * 1e3;
|
|
610
|
+
history = [];
|
|
611
|
+
clients = /* @__PURE__ */ new Set();
|
|
612
|
+
historySize;
|
|
613
|
+
constructor(historySize = 200) {
|
|
614
|
+
this.historySize = historySize;
|
|
615
|
+
}
|
|
616
|
+
publish(event) {
|
|
617
|
+
const envelope = { ...event, id: this.nextId++, ts: (/* @__PURE__ */ new Date()).toISOString() };
|
|
618
|
+
this.history.push(envelope);
|
|
619
|
+
if (this.history.length > this.historySize)
|
|
620
|
+
this.history.shift();
|
|
621
|
+
const frame = format(envelope);
|
|
622
|
+
for (const res of this.clients)
|
|
623
|
+
res.write(frame);
|
|
624
|
+
return envelope;
|
|
625
|
+
}
|
|
626
|
+
subscribe(res, lastEventId) {
|
|
627
|
+
res.writeHead(200, {
|
|
628
|
+
"content-type": "text/event-stream",
|
|
629
|
+
"cache-control": "no-cache",
|
|
630
|
+
connection: "keep-alive",
|
|
631
|
+
"x-accel-buffering": "no"
|
|
632
|
+
});
|
|
633
|
+
res.write(": connected\n\n");
|
|
634
|
+
for (const e of this.history)
|
|
635
|
+
if (e.id > lastEventId)
|
|
636
|
+
res.write(format(e));
|
|
637
|
+
res.write("event: live\ndata: {}\n\n");
|
|
638
|
+
this.clients.add(res);
|
|
639
|
+
const keepalive = setInterval(() => res.write(": ping\n\n"), 25e3);
|
|
640
|
+
res.on("close", () => {
|
|
641
|
+
clearInterval(keepalive);
|
|
642
|
+
this.clients.delete(res);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
get clientCount() {
|
|
646
|
+
return this.clients.size;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
function format(e) {
|
|
650
|
+
return `id: ${e.id}
|
|
651
|
+
data: ${JSON.stringify(e)}
|
|
652
|
+
|
|
653
|
+
`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ../../apps/server/dist/hooks.js
|
|
657
|
+
var isObject2 = (v) => typeof v === "object" && v !== null;
|
|
658
|
+
var str2 = (v) => typeof v === "string" ? v : "";
|
|
659
|
+
function parseHookPayload(body) {
|
|
660
|
+
if (!isObject2(body))
|
|
661
|
+
return null;
|
|
662
|
+
const sessionId = str2(body.session_id);
|
|
663
|
+
const cwd = str2(body.cwd);
|
|
664
|
+
if (!sessionId || !cwd)
|
|
665
|
+
return null;
|
|
666
|
+
const common = {
|
|
667
|
+
sessionId,
|
|
668
|
+
cwd,
|
|
669
|
+
transcriptPath: str2(body.transcript_path) || null
|
|
670
|
+
};
|
|
671
|
+
switch (body.hook_event_name) {
|
|
672
|
+
case "Stop":
|
|
673
|
+
return { ...common, kind: "stop", text: str2(body.last_assistant_message).trim() };
|
|
674
|
+
case "PreToolUse": {
|
|
675
|
+
if (body.tool_name !== "AskUserQuestion") {
|
|
676
|
+
return { ...common, kind: "ignore", reason: `tool ${str2(body.tool_name)}` };
|
|
677
|
+
}
|
|
678
|
+
const input = isObject2(body.tool_input) ? body.tool_input : {};
|
|
679
|
+
const questions = Array.isArray(input.questions) ? input.questions.filter(isObject2).map(parseQuestion) : [];
|
|
680
|
+
if (questions.length === 0)
|
|
681
|
+
return { ...common, kind: "ignore", reason: "no questions" };
|
|
682
|
+
return { ...common, kind: "question", toolUseId: str2(body.tool_use_id), questions };
|
|
683
|
+
}
|
|
684
|
+
case "Notification": {
|
|
685
|
+
if (body.notification_type !== "permission_prompt") {
|
|
686
|
+
return { ...common, kind: "ignore", reason: `notification ${str2(body.notification_type)}` };
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
...common,
|
|
690
|
+
kind: "permission",
|
|
691
|
+
message: str2(body.message) || "Claude needs your permission"
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
default:
|
|
695
|
+
return { ...common, kind: "ignore", reason: `event ${str2(body.hook_event_name)}` };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function parseQuestion(q) {
|
|
699
|
+
const options = Array.isArray(q.options) ? q.options.filter(isObject2).map((o) => ({ label: str2(o.label), description: str2(o.description) })) : [];
|
|
700
|
+
return {
|
|
701
|
+
question: str2(q.question),
|
|
702
|
+
header: str2(q.header),
|
|
703
|
+
multiSelect: q.multiSelect === true,
|
|
704
|
+
options
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
var ORDINALS = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
|
708
|
+
function questionSpeech(questions) {
|
|
709
|
+
return questions.map((q) => {
|
|
710
|
+
const opts = q.options.map((o, i) => `${ORDINALS[i] ?? String(i + 1)}, ${o.label}.`).join(" ");
|
|
711
|
+
return `Question: ${q.question} Options: ${opts}`;
|
|
712
|
+
}).join(" ");
|
|
713
|
+
}
|
|
714
|
+
function truncateSpeech(text, max) {
|
|
715
|
+
if (text.length <= max)
|
|
716
|
+
return text;
|
|
717
|
+
const head = text.slice(0, max);
|
|
718
|
+
const windowStart = Math.max(0, max - 200);
|
|
719
|
+
const window = head.slice(windowStart);
|
|
720
|
+
const end = Math.max(window.lastIndexOf("."), window.lastIndexOf("!"), window.lastIndexOf("?"));
|
|
721
|
+
return end >= 0 ? head.slice(0, windowStart + end + 1) : head;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ../../apps/server/dist/label-clips.js
|
|
725
|
+
var RETRY_AFTER_MS = 6e4;
|
|
726
|
+
var LabelClips = class _LabelClips {
|
|
727
|
+
store;
|
|
728
|
+
synthesize;
|
|
729
|
+
inflight = /* @__PURE__ */ new Map();
|
|
730
|
+
/** id -> time of the last failed synthesis; not retried within RETRY_AFTER_MS. */
|
|
731
|
+
failedAt = /* @__PURE__ */ new Map();
|
|
732
|
+
now;
|
|
733
|
+
constructor(store, synthesize, now = Date.now) {
|
|
734
|
+
this.store = store;
|
|
735
|
+
this.synthesize = synthesize;
|
|
736
|
+
this.now = now;
|
|
737
|
+
}
|
|
738
|
+
static idFor(label) {
|
|
739
|
+
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
740
|
+
return `label-${slug || "session"}`;
|
|
741
|
+
}
|
|
742
|
+
/** Resolves to the clip URL, synthesising it on first use; null when synthesis failed. */
|
|
743
|
+
urlFor(label) {
|
|
744
|
+
const id = _LabelClips.idFor(label);
|
|
745
|
+
const url = `/audio/${id}.mp3`;
|
|
746
|
+
if (this.store.has(id))
|
|
747
|
+
return Promise.resolve(url);
|
|
748
|
+
const pending = this.inflight.get(id);
|
|
749
|
+
if (pending)
|
|
750
|
+
return pending;
|
|
751
|
+
const failed = this.failedAt.get(id);
|
|
752
|
+
if (failed !== void 0 && this.now() - failed < RETRY_AFTER_MS)
|
|
753
|
+
return Promise.resolve(null);
|
|
754
|
+
const job = (async () => {
|
|
755
|
+
try {
|
|
756
|
+
await this.store.write(id, await this.synthesize(spokenLabel(label)));
|
|
757
|
+
this.failedAt.delete(id);
|
|
758
|
+
return url;
|
|
759
|
+
} catch {
|
|
760
|
+
this.failedAt.set(id, this.now());
|
|
761
|
+
return null;
|
|
762
|
+
} finally {
|
|
763
|
+
this.inflight.delete(id);
|
|
764
|
+
}
|
|
765
|
+
})();
|
|
766
|
+
this.inflight.set(id, job);
|
|
767
|
+
return job;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
function spokenLabel(label) {
|
|
771
|
+
return label.replace(/[-_.]+/g, " ").trim();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ../../apps/server/dist/sessions.js
|
|
775
|
+
import { basename } from "node:path";
|
|
776
|
+
var SessionRegistry = class {
|
|
777
|
+
sessions = /* @__PURE__ */ new Map();
|
|
778
|
+
labelUrls = /* @__PURE__ */ new Map();
|
|
779
|
+
settings;
|
|
780
|
+
constructor(settings = { get: () => void 0 }) {
|
|
781
|
+
this.settings = settings;
|
|
782
|
+
}
|
|
783
|
+
/** Record a sighting. Returns the info and whether anything user-visible changed. */
|
|
784
|
+
touch(sessionId, cwd, paneId) {
|
|
785
|
+
const prev = this.sessions.get(sessionId);
|
|
786
|
+
const pane = validPane(paneId) ?? prev?.paneId;
|
|
787
|
+
const label = basename(cwd) || cwd;
|
|
788
|
+
const labelAudioUrl = this.labelUrls.get(label);
|
|
789
|
+
const info = {
|
|
790
|
+
sessionId,
|
|
791
|
+
cwd,
|
|
792
|
+
label,
|
|
793
|
+
...pane ? { paneId: pane } : {},
|
|
794
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
795
|
+
...labelAudioUrl ? { labelAudioUrl } : {},
|
|
796
|
+
speakMode: this.speakMode(cwd)
|
|
797
|
+
};
|
|
798
|
+
this.sessions.set(sessionId, info);
|
|
799
|
+
const changed = !prev || prev.cwd !== cwd || prev.paneId !== info.paneId || prev.labelAudioUrl !== info.labelAudioUrl || prev.speakMode !== info.speakMode;
|
|
800
|
+
return { info, changed };
|
|
801
|
+
}
|
|
802
|
+
/** Remember where a label's spoken clip lives; sessions with that label pick it up. */
|
|
803
|
+
setLabelAudio(label, url) {
|
|
804
|
+
this.labelUrls.set(label, url);
|
|
805
|
+
const updated = [];
|
|
806
|
+
for (const [id, info] of this.sessions) {
|
|
807
|
+
if (info.label === label && info.labelAudioUrl !== url) {
|
|
808
|
+
const next = { ...info, labelAudioUrl: url };
|
|
809
|
+
this.sessions.set(id, next);
|
|
810
|
+
updated.push(next);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return updated;
|
|
814
|
+
}
|
|
815
|
+
/** Re-read settings for every session in `cwd`; returns the ones that changed. */
|
|
816
|
+
refreshSettings(cwd) {
|
|
817
|
+
const updated = [];
|
|
818
|
+
for (const [id, info] of this.sessions) {
|
|
819
|
+
if (info.cwd !== cwd)
|
|
820
|
+
continue;
|
|
821
|
+
const speakMode = this.speakMode(cwd);
|
|
822
|
+
if (info.speakMode === speakMode)
|
|
823
|
+
continue;
|
|
824
|
+
const next = { ...info, speakMode };
|
|
825
|
+
this.sessions.set(id, next);
|
|
826
|
+
updated.push(next);
|
|
827
|
+
}
|
|
828
|
+
return updated;
|
|
829
|
+
}
|
|
830
|
+
speakMode(cwd) {
|
|
831
|
+
return this.settings.get(cwd)?.speakMode ?? "full";
|
|
832
|
+
}
|
|
833
|
+
/** Forget a session that opted out; returns whether it was known. */
|
|
834
|
+
remove(sessionId) {
|
|
835
|
+
return this.sessions.delete(sessionId);
|
|
836
|
+
}
|
|
837
|
+
get(sessionId) {
|
|
838
|
+
return this.sessions.get(sessionId);
|
|
839
|
+
}
|
|
840
|
+
list() {
|
|
841
|
+
return [...this.sessions.values()].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
function validPane(paneId) {
|
|
845
|
+
return paneId && /^[\w:.][\w:.-]*$/.test(paneId) ? paneId : null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ../../apps/server/dist/settings-store.js
|
|
849
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
850
|
+
import { dirname } from "node:path";
|
|
851
|
+
var MODES = ["full", "summary"];
|
|
852
|
+
function isSpeakMode(v) {
|
|
853
|
+
return typeof v === "string" && MODES.includes(v);
|
|
854
|
+
}
|
|
855
|
+
var SettingsStore = class {
|
|
856
|
+
file;
|
|
857
|
+
byCwd = /* @__PURE__ */ new Map();
|
|
858
|
+
constructor(file) {
|
|
859
|
+
this.file = file;
|
|
860
|
+
try {
|
|
861
|
+
const parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
862
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
863
|
+
for (const [cwd, value] of Object.entries(parsed)) {
|
|
864
|
+
const mode = value?.speakMode;
|
|
865
|
+
if (isSpeakMode(mode))
|
|
866
|
+
this.byCwd.set(cwd, { speakMode: mode });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
} catch {
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
get(cwd) {
|
|
873
|
+
return this.byCwd.get(cwd);
|
|
874
|
+
}
|
|
875
|
+
set(cwd, settings) {
|
|
876
|
+
this.byCwd.set(cwd, settings);
|
|
877
|
+
mkdirSync2(dirname(this.file), { recursive: true });
|
|
878
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
879
|
+
writeFileSync(tmp, `${JSON.stringify(Object.fromEntries(this.byCwd), null, 2)}
|
|
880
|
+
`);
|
|
881
|
+
renameSync(tmp, this.file);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// ../../apps/server/dist/speech-render.js
|
|
886
|
+
var UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
887
|
+
var HEX_HASH = /\b(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{7,64}\b/gi;
|
|
888
|
+
var URL2 = /\bhttps?:\/\/([^\s/)\]>"']+)[^\s)\]>"']*/gi;
|
|
889
|
+
var PATH = /(?<![\w@])(?:~\/|\.{1,2}\/|\/)?(?:[\w.@-]+\/)+[\w.@-]+(?:\.\w{1,10})?(?![\w/])|(?<![\w@/])(?:~\/|\.{1,2}\/)[\w.@-]+/g;
|
|
890
|
+
var INLINE_CODE = /`([^`\n]+)`/g;
|
|
891
|
+
var FENCE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\1[ \t]*$/gm;
|
|
892
|
+
function renderSpeech(text, options = {}) {
|
|
893
|
+
let out = text.replace(/\r\n?/g, "\n");
|
|
894
|
+
out = out.replace(FENCE, (_m, _fence, body) => {
|
|
895
|
+
const lines2 = body.split("\n").filter((l) => l.trim() !== "").length;
|
|
896
|
+
return `
|
|
897
|
+
code block, ${lines2} ${lines2 === 1 ? "line" : "lines"}.
|
|
898
|
+
`;
|
|
899
|
+
});
|
|
900
|
+
out = renderTables(out);
|
|
901
|
+
out = out.replace(INLINE_CODE, (_m, code) => code.length <= 30 ? code : "code");
|
|
902
|
+
out = out.replace(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, `${HEADING}$1.`);
|
|
903
|
+
out = out.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
|
904
|
+
out = out.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
905
|
+
out = out.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "");
|
|
906
|
+
out = out.replace(/^\s*>\s?/gm, "");
|
|
907
|
+
out = out.replace(/^\s*([-*_])\s*(?:\1\s*){2,}$/gm, "");
|
|
908
|
+
out = out.replace(/(\*\*|__)(.+?)\1/g, "$2");
|
|
909
|
+
out = out.replace(/(?<![\w*])[*_](?![\s*_])(.+?)(?<![\s*_])[*_](?![\w*])/g, "$1");
|
|
910
|
+
out = out.replace(/~~(.+?)~~/g, "$1");
|
|
911
|
+
out = out.replace(URL2, (_m, host) => host);
|
|
912
|
+
out = out.replace(UUID, "an id");
|
|
913
|
+
out = out.replace(HEX_HASH, "an id");
|
|
914
|
+
out = out.replace(PATH, (m) => spokenPath(m));
|
|
915
|
+
out = out.replace(/(?<=[A-Za-z_])\d{3,}(?=[A-Za-z_]|\b)/g, "");
|
|
916
|
+
out = out.replace(/\b\d{5,}(?![a-z]{1,2}\b)(?=[A-Za-z_])/g, "");
|
|
917
|
+
let lines = out.split("\n").map((l) => l.replace(/\s+/g, " ").trim());
|
|
918
|
+
if (options.summary)
|
|
919
|
+
lines = firstParagraph(lines);
|
|
920
|
+
return lines.map((l) => l.replaceAll(HEADING, "")).filter((l) => l.length > 0).map((l) => /[.!?:;,]$/.test(l) ? l : `${l}.`).join(" ").replace(/\s+/g, " ").trim();
|
|
921
|
+
}
|
|
922
|
+
var HEADING = "";
|
|
923
|
+
function firstParagraph(lines) {
|
|
924
|
+
const blocks = [];
|
|
925
|
+
let current = [];
|
|
926
|
+
for (const line of lines) {
|
|
927
|
+
if (line.length === 0) {
|
|
928
|
+
if (current.length)
|
|
929
|
+
blocks.push(current);
|
|
930
|
+
current = [];
|
|
931
|
+
} else
|
|
932
|
+
current.push(line);
|
|
933
|
+
}
|
|
934
|
+
if (current.length)
|
|
935
|
+
blocks.push(current);
|
|
936
|
+
const out = [];
|
|
937
|
+
for (const block of blocks) {
|
|
938
|
+
out.push(...block);
|
|
939
|
+
const titleOnly = block.length === 1 && (block[0]?.startsWith(HEADING) || !/\s/.test(block[0] ?? ""));
|
|
940
|
+
if (!titleOnly)
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
function spokenPath(path) {
|
|
946
|
+
const last = path.split("/").filter(Boolean).pop() ?? path;
|
|
947
|
+
return last.replace(/\./g, " dot ").replace(/\s+/g, " ").trim();
|
|
948
|
+
}
|
|
949
|
+
function renderTables(text) {
|
|
950
|
+
const lines = text.split("\n");
|
|
951
|
+
const out = [];
|
|
952
|
+
for (let i = 0; i < lines.length; i++) {
|
|
953
|
+
const line = lines[i] ?? "";
|
|
954
|
+
const next = lines[i + 1] ?? "";
|
|
955
|
+
if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{3,}/.test(next)) {
|
|
956
|
+
let j = i + 2;
|
|
957
|
+
while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j] ?? ""))
|
|
958
|
+
j++;
|
|
959
|
+
const rows = j - (i + 2);
|
|
960
|
+
out.push(`table with ${rows} ${rows === 1 ? "row" : "rows"}.`);
|
|
961
|
+
i = j - 1;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
out.push(line);
|
|
965
|
+
}
|
|
966
|
+
return out.join("\n");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ../../apps/server/dist/static.js
|
|
970
|
+
import { createReadStream as createReadStream2, existsSync as existsSync3, statSync as statSync2 } from "node:fs";
|
|
971
|
+
import { extname, join as join3, normalize } from "node:path";
|
|
972
|
+
var TYPES = {
|
|
973
|
+
".html": "text/html; charset=utf-8",
|
|
974
|
+
".js": "text/javascript; charset=utf-8",
|
|
975
|
+
".css": "text/css; charset=utf-8",
|
|
976
|
+
".svg": "image/svg+xml",
|
|
977
|
+
".png": "image/png",
|
|
978
|
+
".ico": "image/x-icon",
|
|
979
|
+
".json": "application/json",
|
|
980
|
+
".webmanifest": "application/manifest+json",
|
|
981
|
+
".woff2": "font/woff2"
|
|
982
|
+
};
|
|
983
|
+
function serveStatic(dir, req, res, pathname) {
|
|
984
|
+
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, "");
|
|
985
|
+
let file = join3(dir, safe);
|
|
986
|
+
if (!file.startsWith(dir) || !existsSync3(file) || statSync2(file).isDirectory()) {
|
|
987
|
+
file = join3(dir, "index.html");
|
|
988
|
+
}
|
|
989
|
+
if (!existsSync3(file)) {
|
|
990
|
+
res.writeHead(404).end("web app not built; run pnpm build");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const isAsset = safe.startsWith("/assets/");
|
|
994
|
+
res.writeHead(200, {
|
|
995
|
+
"content-type": TYPES[extname(file)] ?? "application/octet-stream",
|
|
996
|
+
"content-length": statSync2(file).size,
|
|
997
|
+
"cache-control": isAsset ? "public, max-age=31536000, immutable" : "no-cache"
|
|
998
|
+
});
|
|
999
|
+
if (req.method === "HEAD")
|
|
1000
|
+
res.end();
|
|
1001
|
+
else
|
|
1002
|
+
createReadStream2(file).pipe(res);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ../../apps/server/dist/transcript.js
|
|
1006
|
+
import { realpathSync } from "node:fs";
|
|
1007
|
+
import { open, realpath } from "node:fs/promises";
|
|
1008
|
+
import { homedir } from "node:os";
|
|
1009
|
+
import { join as join4, sep } from "node:path";
|
|
1010
|
+
var TAIL_BYTES = 256 * 1024;
|
|
1011
|
+
function transcriptRoot() {
|
|
1012
|
+
const dir = join4(homedir(), ".claude", "projects");
|
|
1013
|
+
try {
|
|
1014
|
+
return realpathSync(dir) + sep;
|
|
1015
|
+
} catch {
|
|
1016
|
+
return dir + sep;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
async function lastAssistantText(transcriptPath, root = transcriptRoot()) {
|
|
1020
|
+
let path;
|
|
1021
|
+
try {
|
|
1022
|
+
path = await realpath(transcriptPath);
|
|
1023
|
+
} catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
if (!path.startsWith(root) || !path.endsWith(".jsonl"))
|
|
1027
|
+
return null;
|
|
1028
|
+
let raw;
|
|
1029
|
+
try {
|
|
1030
|
+
const fh = await open(path, "r");
|
|
1031
|
+
try {
|
|
1032
|
+
const { size } = await fh.stat();
|
|
1033
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
1034
|
+
const buf = Buffer.alloc(length);
|
|
1035
|
+
await fh.read(buf, 0, length, size - length);
|
|
1036
|
+
raw = buf.toString("utf8");
|
|
1037
|
+
if (length < size)
|
|
1038
|
+
raw = raw.slice(raw.indexOf("\n") + 1);
|
|
1039
|
+
} finally {
|
|
1040
|
+
await fh.close();
|
|
1041
|
+
}
|
|
1042
|
+
} catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
const lines = raw.split("\n");
|
|
1046
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1047
|
+
const line = lines[i]?.trim();
|
|
1048
|
+
if (!line)
|
|
1049
|
+
continue;
|
|
1050
|
+
let entry;
|
|
1051
|
+
try {
|
|
1052
|
+
entry = JSON.parse(line);
|
|
1053
|
+
} catch {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const text = assistantText(entry);
|
|
1057
|
+
if (text)
|
|
1058
|
+
return text;
|
|
1059
|
+
}
|
|
1060
|
+
return "";
|
|
1061
|
+
}
|
|
1062
|
+
function assistantText(entry) {
|
|
1063
|
+
if (typeof entry !== "object" || entry === null)
|
|
1064
|
+
return "";
|
|
1065
|
+
const e = entry;
|
|
1066
|
+
if (e.type !== "assistant" || !Array.isArray(e.message?.content))
|
|
1067
|
+
return "";
|
|
1068
|
+
return e.message.content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ../../apps/server/dist/server.js
|
|
1072
|
+
var HOOK_BODY_LIMIT = 1e6;
|
|
1073
|
+
var COMMAND_BODY_LIMIT = 64 * 1024;
|
|
1074
|
+
var UPLOAD_LIMIT = 10 * 1024 * 1024;
|
|
1075
|
+
var TRANSCRIPT_LAG_MS = 750;
|
|
1076
|
+
var AUDIO_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
1077
|
+
async function startServer(cfg) {
|
|
1078
|
+
if (!cfg.accessKey)
|
|
1079
|
+
throw new Error("RAYDR_ACCESS_KEY is empty; run raydr init or raydr serve");
|
|
1080
|
+
const audio = new AudioStore(cfg.cacheDir);
|
|
1081
|
+
const pruned = await audio.prune(AUDIO_MAX_AGE_MS);
|
|
1082
|
+
if (pruned)
|
|
1083
|
+
log(`pruned ${pruned} stale audio file(s)`);
|
|
1084
|
+
const speech = selectSpeechProvider(cfg);
|
|
1085
|
+
log(speech ? `speech provider: ${speech.name}` : "speech disabled (no provider credentials)");
|
|
1086
|
+
const transcription = selectTranscriptionProvider(cfg);
|
|
1087
|
+
const bus = new EventBus();
|
|
1088
|
+
const settings = new SettingsStore(join5(cfg.stateDir, "sessions.json"));
|
|
1089
|
+
const sessions = new SessionRegistry(settings);
|
|
1090
|
+
const labels = speech ? new LabelClips(audio, (text) => speech.synthesize(text)) : null;
|
|
1091
|
+
const webDir = cfg.webDir ?? defaultWebDir();
|
|
1092
|
+
const commands = new CommandRunner(herdrCli, sessions);
|
|
1093
|
+
const app = {
|
|
1094
|
+
cfg,
|
|
1095
|
+
audio,
|
|
1096
|
+
speech,
|
|
1097
|
+
transcription,
|
|
1098
|
+
bus,
|
|
1099
|
+
sessions,
|
|
1100
|
+
settings,
|
|
1101
|
+
labels,
|
|
1102
|
+
commands,
|
|
1103
|
+
webDir,
|
|
1104
|
+
startedAt: Date.now()
|
|
1105
|
+
};
|
|
1106
|
+
const server = createServer((req, res) => {
|
|
1107
|
+
handle(app, req, res).catch((err) => {
|
|
1108
|
+
log(`request error: ${err.message}`);
|
|
1109
|
+
if (!res.headersSent)
|
|
1110
|
+
res.writeHead(500);
|
|
1111
|
+
res.end();
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
await new Promise((resolve4, reject) => {
|
|
1115
|
+
server.once("error", reject);
|
|
1116
|
+
server.listen(cfg.port, cfg.host, () => resolve4());
|
|
1117
|
+
});
|
|
1118
|
+
log(`listening on http://${cfg.host}:${cfg.port}`);
|
|
1119
|
+
if (!webDir)
|
|
1120
|
+
log("web app not built; only the API is available");
|
|
1121
|
+
return {
|
|
1122
|
+
server,
|
|
1123
|
+
bus,
|
|
1124
|
+
sessions,
|
|
1125
|
+
close: () => new Promise((resolve4) => {
|
|
1126
|
+
server.closeAllConnections();
|
|
1127
|
+
server.close(() => resolve4());
|
|
1128
|
+
})
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
async function handle(app, req, res) {
|
|
1132
|
+
const url = new URL(req.url ?? "/", "http://raydr");
|
|
1133
|
+
const path = url.pathname;
|
|
1134
|
+
if (path === "/healthz") {
|
|
1135
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1136
|
+
res.end(JSON.stringify({
|
|
1137
|
+
ok: true,
|
|
1138
|
+
speech: app.speech?.name ?? null,
|
|
1139
|
+
clients: app.bus.clientCount,
|
|
1140
|
+
pid: process.pid,
|
|
1141
|
+
uptimeSeconds: Math.round((Date.now() - app.startedAt) / 1e3),
|
|
1142
|
+
port: app.cfg.port
|
|
1143
|
+
}));
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (req.method === "POST" && path.startsWith("/hooks/")) {
|
|
1147
|
+
if (!authorized(app.cfg.accessKey, req, url)) {
|
|
1148
|
+
logUnauthorizedHook(req);
|
|
1149
|
+
res.writeHead(401).end();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
1153
|
+
res.writeHead(415).end();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const body = await readJson(req);
|
|
1157
|
+
res.writeHead(200).end();
|
|
1158
|
+
const paneId = headerValue(req, "x-raydr-pane") ?? headerValue(req, "x-raydar-pane");
|
|
1159
|
+
processHook(app, body, paneId).catch((err) => log(`hook error: ${err.message}`));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (path === "/events" || path === "/sessions" || path.startsWith("/sessions/") || path === "/commands" || path === "/transcribe" || path.startsWith("/audio/")) {
|
|
1163
|
+
if (!authorized(app.cfg.accessKey, req, url)) {
|
|
1164
|
+
res.writeHead(401, { "content-type": "text/plain" }).end("access key required");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (path === "/events") {
|
|
1169
|
+
const last = Number(req.headers["last-event-id"] ?? url.searchParams.get("lastEventId") ?? 0);
|
|
1170
|
+
app.bus.subscribe(res, Number.isFinite(last) ? last : 0);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (path === "/commands" && req.method === "POST") {
|
|
1174
|
+
const cmd = parseCommand(await readJson(req, COMMAND_BODY_LIMIT));
|
|
1175
|
+
if (!cmd) {
|
|
1176
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1177
|
+
res.end(JSON.stringify({ error: "bad_command" }));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
res.writeHead(202, { "content-type": "application/json" });
|
|
1181
|
+
res.end(JSON.stringify({ commandId: cmd.commandId }));
|
|
1182
|
+
runCommand(app, cmd).catch((err) => log(`command error: ${err.message}`));
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (path === "/transcribe" && req.method === "POST") {
|
|
1186
|
+
await transcribe(app, req, res);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (path === "/sessions/announce" && req.method === "POST") {
|
|
1190
|
+
const body = await readJson(req, COMMAND_BODY_LIMIT);
|
|
1191
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : "";
|
|
1192
|
+
const cwd = typeof body?.cwd === "string" ? body.cwd : "";
|
|
1193
|
+
const state = body?.state;
|
|
1194
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(sessionId) || !cwd || state !== "joined" && state !== "left") {
|
|
1195
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1196
|
+
res.end(JSON.stringify({ error: "bad_announcement" }));
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (state === "joined") {
|
|
1200
|
+
const paneId = typeof body?.paneId === "string" ? body.paneId : null;
|
|
1201
|
+
const { info } = app.sessions.touch(sessionId, cwd, paneId);
|
|
1202
|
+
publishSession(app, info);
|
|
1203
|
+
log(`session joined: ${info.label} (${sessionId.slice(0, 8)})`);
|
|
1204
|
+
} else if (app.sessions.remove(sessionId)) {
|
|
1205
|
+
app.commands.clearPending(sessionId);
|
|
1206
|
+
app.bus.publish({ type: "session_left", sessionId });
|
|
1207
|
+
log(`session left: ${sessionId.slice(0, 8)}`);
|
|
1208
|
+
}
|
|
1209
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1210
|
+
res.end(JSON.stringify({ state }));
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const settingsMatch = req.method === "PUT" && /^\/sessions\/([^/]+)\/settings$/.exec(path);
|
|
1214
|
+
if (settingsMatch) {
|
|
1215
|
+
const session2 = app.sessions.get(safeDecode(settingsMatch[1] ?? ""));
|
|
1216
|
+
const body = await readJson(req, COMMAND_BODY_LIMIT);
|
|
1217
|
+
if (!session2) {
|
|
1218
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1219
|
+
res.end(JSON.stringify({ error: "unknown_session" }));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (!isSpeakMode(body?.speakMode)) {
|
|
1223
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1224
|
+
res.end(JSON.stringify({ error: "bad_settings" }));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
app.settings.set(session2.cwd, { speakMode: body.speakMode });
|
|
1228
|
+
for (const info of app.sessions.refreshSettings(session2.cwd))
|
|
1229
|
+
publishSession(app, info);
|
|
1230
|
+
log(`speak mode for ${session2.label}: ${body.speakMode}`);
|
|
1231
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1232
|
+
res.end(JSON.stringify({ speakMode: body.speakMode }));
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (path === "/sessions") {
|
|
1236
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1237
|
+
res.end(JSON.stringify(app.sessions.list()));
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (path.startsWith("/audio/")) {
|
|
1241
|
+
const id = path.slice("/audio/".length).replace(/\.mp3$/, "");
|
|
1242
|
+
if (!/^[\w-]+$/.test(id)) {
|
|
1243
|
+
res.writeHead(400).end();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const clip = app.audio.open(id);
|
|
1247
|
+
if (!clip) {
|
|
1248
|
+
res.writeHead(404).end();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const headers = {
|
|
1252
|
+
"content-type": "audio/mpeg",
|
|
1253
|
+
"cache-control": "private, max-age=3600"
|
|
1254
|
+
};
|
|
1255
|
+
if (clip.size !== null)
|
|
1256
|
+
headers["content-length"] = clip.size;
|
|
1257
|
+
res.writeHead(200, headers);
|
|
1258
|
+
clip.stream.on("error", () => res.destroy());
|
|
1259
|
+
clip.stream.pipe(res);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if ((req.method === "GET" || req.method === "HEAD") && app.webDir) {
|
|
1263
|
+
serveStatic(app.webDir, req, res, path);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
res.writeHead(404).end();
|
|
1267
|
+
}
|
|
1268
|
+
async function processHook(app, body, paneId) {
|
|
1269
|
+
const hook = parseHookPayload(body);
|
|
1270
|
+
if (!hook) {
|
|
1271
|
+
log("hook: unparseable payload");
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const { info, changed } = app.sessions.touch(hook.sessionId, hook.cwd, paneId);
|
|
1275
|
+
if (changed)
|
|
1276
|
+
publishSession(app, info);
|
|
1277
|
+
if (app.labels && !info.labelAudioUrl) {
|
|
1278
|
+
const label = info.label;
|
|
1279
|
+
app.labels.urlFor(label).then((url) => {
|
|
1280
|
+
if (url)
|
|
1281
|
+
for (const updated of app.sessions.setLabelAudio(label, url))
|
|
1282
|
+
publishSession(app, updated);
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
const event = await toEvent(app, hook);
|
|
1286
|
+
if (!event)
|
|
1287
|
+
return;
|
|
1288
|
+
if (event.type === "question") {
|
|
1289
|
+
app.commands.setPending(hook.sessionId, {
|
|
1290
|
+
toolUseId: event.toolUseId,
|
|
1291
|
+
questions: event.questions
|
|
1292
|
+
});
|
|
1293
|
+
} else if (event.type === "permission") {
|
|
1294
|
+
app.commands.setPendingPermission(hook.sessionId);
|
|
1295
|
+
} else if (event.type === "assistant_message") {
|
|
1296
|
+
app.commands.clearPending(hook.sessionId);
|
|
1297
|
+
}
|
|
1298
|
+
app.bus.publish(event);
|
|
1299
|
+
log(`${hook.kind} from ${info.label} (${hook.sessionId.slice(0, 8)})${"audioUrl" in event && event.audioUrl ? ` -> ${event.audioUrl}` : ""}`);
|
|
1300
|
+
}
|
|
1301
|
+
async function toEvent(app, hook) {
|
|
1302
|
+
switch (hook.kind) {
|
|
1303
|
+
case "ignore":
|
|
1304
|
+
return null;
|
|
1305
|
+
case "stop": {
|
|
1306
|
+
let text = hook.text;
|
|
1307
|
+
if (!text && hook.transcriptPath) {
|
|
1308
|
+
await new Promise((r) => setTimeout(r, TRANSCRIPT_LAG_MS));
|
|
1309
|
+
const fromTranscript = await lastAssistantText(hook.transcriptPath);
|
|
1310
|
+
if (fromTranscript === null)
|
|
1311
|
+
log(`transcript rejected: ${hook.transcriptPath}`);
|
|
1312
|
+
text = fromTranscript ?? "";
|
|
1313
|
+
}
|
|
1314
|
+
if (!text)
|
|
1315
|
+
return null;
|
|
1316
|
+
return {
|
|
1317
|
+
type: "assistant_message",
|
|
1318
|
+
sessionId: hook.sessionId,
|
|
1319
|
+
text,
|
|
1320
|
+
...spoken(app, renderSpeech(text, { summary: app.sessions.speakMode(hook.cwd) === "summary" }))
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
case "question":
|
|
1324
|
+
return {
|
|
1325
|
+
type: "question",
|
|
1326
|
+
sessionId: hook.sessionId,
|
|
1327
|
+
toolUseId: hook.toolUseId,
|
|
1328
|
+
questions: hook.questions,
|
|
1329
|
+
...spoken(app, renderSpeech(questionSpeech(hook.questions)))
|
|
1330
|
+
};
|
|
1331
|
+
case "permission":
|
|
1332
|
+
return {
|
|
1333
|
+
type: "permission",
|
|
1334
|
+
sessionId: hook.sessionId,
|
|
1335
|
+
notificationType: "permission_prompt",
|
|
1336
|
+
message: hook.message,
|
|
1337
|
+
...spoken(app, renderSpeech(hook.message))
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
function publishSession(app, info) {
|
|
1342
|
+
app.bus.publish({
|
|
1343
|
+
type: "session_seen",
|
|
1344
|
+
sessionId: info.sessionId,
|
|
1345
|
+
cwd: info.cwd,
|
|
1346
|
+
label: info.label,
|
|
1347
|
+
...info.paneId ? { paneId: info.paneId } : {},
|
|
1348
|
+
lastSeen: info.lastSeen,
|
|
1349
|
+
...info.labelAudioUrl ? { labelAudioUrl: info.labelAudioUrl } : {},
|
|
1350
|
+
speakMode: info.speakMode ?? "full"
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
function spoken(app, speech) {
|
|
1354
|
+
const text = truncateSpeech(speech, app.cfg.maxSpeechChars);
|
|
1355
|
+
const url = text ? speak(app, text) : null;
|
|
1356
|
+
return url ? { speech: text, audioUrl: url } : { speech: text };
|
|
1357
|
+
}
|
|
1358
|
+
async function runCommand(app, cmd) {
|
|
1359
|
+
const base = {
|
|
1360
|
+
type: "command_result",
|
|
1361
|
+
sessionId: cmd.sessionId,
|
|
1362
|
+
commandId: cmd.commandId
|
|
1363
|
+
};
|
|
1364
|
+
try {
|
|
1365
|
+
await app.commands.run(cmd);
|
|
1366
|
+
app.bus.publish({ ...base, ok: true });
|
|
1367
|
+
log(`command ${cmd.type} ok (${cmd.commandId})`);
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
const error = err instanceof CommandError ? err.code : "internal_error";
|
|
1370
|
+
log(`command ${cmd.type} failed: ${error}: ${err.message}`);
|
|
1371
|
+
app.bus.publish({ ...base, ok: false, error });
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
async function transcribe(app, req, res) {
|
|
1375
|
+
if (!app.transcription) {
|
|
1376
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
1377
|
+
res.end(JSON.stringify({ error: "transcription_unavailable" }));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
1381
|
+
if (!contentType.startsWith("multipart/form-data")) {
|
|
1382
|
+
res.writeHead(415).end();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
let body;
|
|
1386
|
+
try {
|
|
1387
|
+
body = await readBody(req, UPLOAD_LIMIT);
|
|
1388
|
+
} catch {
|
|
1389
|
+
res.writeHead(413).end();
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const bytes = new Uint8Array(body);
|
|
1393
|
+
let file;
|
|
1394
|
+
try {
|
|
1395
|
+
const form = await new Response(bytes, { headers: { "content-type": contentType } }).formData();
|
|
1396
|
+
file = form.get("file");
|
|
1397
|
+
} catch {
|
|
1398
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1399
|
+
res.end(JSON.stringify({ error: "bad_multipart" }));
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (!(file instanceof Blob) || file.size === 0) {
|
|
1403
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1404
|
+
res.end(JSON.stringify({ error: "missing_file" }));
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const filename = file instanceof File ? file.name : "audio";
|
|
1408
|
+
let text;
|
|
1409
|
+
try {
|
|
1410
|
+
text = await app.transcription.transcribe(file, filename);
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
const invalid = err instanceof TranscriptionError && err.status === 400;
|
|
1413
|
+
log(`transcription failed: ${err.message}`);
|
|
1414
|
+
res.writeHead(invalid ? 400 : 502, { "content-type": "application/json" });
|
|
1415
|
+
res.end(JSON.stringify({ error: invalid ? "invalid_audio" : "transcription_failed" }));
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
log(`transcribed ${file.size} bytes -> ${text.length} chars`);
|
|
1419
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1420
|
+
res.end(JSON.stringify({ text }));
|
|
1421
|
+
}
|
|
1422
|
+
function speak(app, text) {
|
|
1423
|
+
if (!app.speech)
|
|
1424
|
+
return null;
|
|
1425
|
+
const id = randomUUID();
|
|
1426
|
+
const speech = app.speech;
|
|
1427
|
+
const pending = new PassThrough2();
|
|
1428
|
+
app.audio.write(id, pending).catch((err) => log(`speech failed: ${err.message}`));
|
|
1429
|
+
speech.synthesize(text).then((stream) => {
|
|
1430
|
+
stream.on("error", (err) => pending.destroy(err));
|
|
1431
|
+
stream.pipe(pending);
|
|
1432
|
+
}).catch((err) => pending.destroy(err));
|
|
1433
|
+
return `/audio/${id}.mp3`;
|
|
1434
|
+
}
|
|
1435
|
+
function authorized(key, req, url) {
|
|
1436
|
+
const header = req.headers.authorization;
|
|
1437
|
+
const supplied = header?.startsWith("Bearer ") ? header.slice(7) : url.searchParams.get("key");
|
|
1438
|
+
if (!supplied)
|
|
1439
|
+
return false;
|
|
1440
|
+
const a = Buffer.from(supplied);
|
|
1441
|
+
const b = Buffer.from(key);
|
|
1442
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1443
|
+
}
|
|
1444
|
+
var lastUnauthorizedLog = 0;
|
|
1445
|
+
function logUnauthorizedHook(req) {
|
|
1446
|
+
const now = Date.now();
|
|
1447
|
+
if (now - lastUnauthorizedLog < 6e4)
|
|
1448
|
+
return;
|
|
1449
|
+
lastUnauthorizedLog = now;
|
|
1450
|
+
log(`unauthorized hook POST from ${req.socket.remoteAddress ?? "?"}; reinstall hooks if this is Claude Code`);
|
|
1451
|
+
}
|
|
1452
|
+
function safeDecode(segment) {
|
|
1453
|
+
try {
|
|
1454
|
+
return decodeURIComponent(segment);
|
|
1455
|
+
} catch {
|
|
1456
|
+
return "";
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
function headerValue(req, name) {
|
|
1460
|
+
const v = req.headers[name];
|
|
1461
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
1462
|
+
return s?.trim() || null;
|
|
1463
|
+
}
|
|
1464
|
+
async function readJson(req, limit = HOOK_BODY_LIMIT) {
|
|
1465
|
+
const body = await readBody(req, limit);
|
|
1466
|
+
try {
|
|
1467
|
+
return body.length ? JSON.parse(body.toString("utf8")) : null;
|
|
1468
|
+
} catch {
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
function readBody(req, limit) {
|
|
1473
|
+
return new Promise((resolve4, reject) => {
|
|
1474
|
+
const chunks = [];
|
|
1475
|
+
let size = 0;
|
|
1476
|
+
req.on("data", (c) => {
|
|
1477
|
+
size += c.length;
|
|
1478
|
+
if (size > limit) {
|
|
1479
|
+
reject(new Error("body too large"));
|
|
1480
|
+
req.destroy();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
chunks.push(c);
|
|
1484
|
+
});
|
|
1485
|
+
req.on("end", () => resolve4(Buffer.concat(chunks)));
|
|
1486
|
+
req.on("error", reject);
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
function defaultWebDir() {
|
|
1490
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
1491
|
+
const candidates = [resolve(here, "..", "web"), resolve(here, "..", "..", "web", "dist")];
|
|
1492
|
+
return candidates.find((dir) => existsSync4(join5(dir, "index.html"))) ?? null;
|
|
1493
|
+
}
|
|
1494
|
+
function log(msg) {
|
|
1495
|
+
console.log(`[raydr ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/command.ts
|
|
1499
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1500
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
1501
|
+
|
|
1502
|
+
// src/command-text.ts
|
|
1503
|
+
var RAYDR_COMMAND = "---\nname: raydr\nallowed-tools: Bash(raydr session:*)\ndescription: Turn raydr on or off for this session, or show its state\n---\n\n## Your task\n\nraydr speaks this session's replies and prompts on the user's phone. Run exactly one command\nwith the Bash tool, chosen by `$ARGUMENTS`, then print its single output line verbatim, with no\ncommentary and no extra formatting:\n\n| Arguments | Run |\n|---|---|\n| empty | `raydr session toggle` |\n| `on` | `raydr session on` |\n| `off` | `raydr session off` |\n| `status` | `raydr session status` |\n\nIf the command fails with `command not found`, raydr is not on this session's PATH. Print\nexactly:\n`raydr is not installed here: run npm i -g @tensorkit/raydr (or, from a checkout, cd packages/cli && npm link), then run /raydr again`\n\nAny other argument: print `usage: /raydr [on|off|status]` and stop.\n\nNote: when the environment has `RAYDR_SESSIONS=all`, every session is heard and this toggle\nchanges nothing; the command's output line says so.\n";
|
|
1504
|
+
|
|
1505
|
+
// src/command.ts
|
|
1506
|
+
var MARKER = "name: raydr";
|
|
1507
|
+
function commandText() {
|
|
1508
|
+
return RAYDR_COMMAND;
|
|
1509
|
+
}
|
|
1510
|
+
function commandFileFor(settingsPath) {
|
|
1511
|
+
return join6(dirname3(settingsPath), "commands", "raydr.md");
|
|
1512
|
+
}
|
|
1513
|
+
function installCommand(target) {
|
|
1514
|
+
mkdirSync3(dirname3(target), { recursive: true });
|
|
1515
|
+
writeFileSync2(target, RAYDR_COMMAND);
|
|
1516
|
+
const old = join6(dirname3(target), "raydar.md");
|
|
1517
|
+
if (existsSync5(old) && readFileSync3(old, "utf8").includes("name: raydar")) unlinkSync(old);
|
|
1518
|
+
return target;
|
|
1519
|
+
}
|
|
1520
|
+
function isRaydrCommandFile(path) {
|
|
1521
|
+
try {
|
|
1522
|
+
return readFileSync3(path, "utf8").includes(MARKER);
|
|
1523
|
+
} catch {
|
|
1524
|
+
return false;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function removeCommand(path) {
|
|
1528
|
+
if (!existsSync5(path) || !isRaydrCommandFile(path)) return false;
|
|
1529
|
+
unlinkSync(path);
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// src/doctor.ts
|
|
1534
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1535
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, statSync as statSync3 } from "node:fs";
|
|
1536
|
+
import { homedir as homedir2 } from "node:os";
|
|
1537
|
+
import { join as join9, resolve as resolve2 } from "node:path";
|
|
1538
|
+
|
|
1539
|
+
// src/hooks-config.ts
|
|
1540
|
+
var HOOK_TIMEOUT_SECONDS = 5;
|
|
1541
|
+
function raydrHookGroups(cliEntry2, url, nodePath = process.execPath) {
|
|
1542
|
+
const hook = (event) => ({
|
|
1543
|
+
type: "command",
|
|
1544
|
+
command: `${quote(nodePath)} ${quote(cliEntry2)} hook ${event}${url ? ` --url ${quote(url)}` : ""}`,
|
|
1545
|
+
timeout: HOOK_TIMEOUT_SECONDS
|
|
1546
|
+
});
|
|
1547
|
+
return {
|
|
1548
|
+
PreToolUse: { matcher: "AskUserQuestion", hooks: [hook("pretooluse")] },
|
|
1549
|
+
Stop: { hooks: [hook("stop")] },
|
|
1550
|
+
Notification: { hooks: [hook("notification")] }
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
function quote(s) {
|
|
1554
|
+
return /^[\w./:@-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
|
1555
|
+
}
|
|
1556
|
+
function isRaydrHook(hook) {
|
|
1557
|
+
if (typeof hook !== "object" || hook === null) return false;
|
|
1558
|
+
const h = hook;
|
|
1559
|
+
if (h.type === "command" && typeof h.command === "string") {
|
|
1560
|
+
return /\S*index\.js\S* hook (pretooluse|stop|notification)\b/.test(h.command);
|
|
1561
|
+
}
|
|
1562
|
+
if (h.type === "http" && typeof h.url === "string") {
|
|
1563
|
+
const hasPaneHeader = typeof h.headers === "object" && h.headers !== null && "X-Raydr-Pane" in h.headers;
|
|
1564
|
+
return hasPaneHeader && /\/hooks\/(pretooluse|stop|notification)$/.test(h.url);
|
|
1565
|
+
}
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
function uninstallHooks(settings) {
|
|
1569
|
+
const out = { ...settings };
|
|
1570
|
+
if (!settings.hooks) return out;
|
|
1571
|
+
const hooks = {};
|
|
1572
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
1573
|
+
const kept = groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isRaydrHook(h)) })).filter((g) => g.hooks.length > 0);
|
|
1574
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
1575
|
+
}
|
|
1576
|
+
if (Object.keys(hooks).length > 0) out.hooks = hooks;
|
|
1577
|
+
else delete out.hooks;
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
function installHooks(settings, cliEntry2, url, nodePath = process.execPath) {
|
|
1581
|
+
const out = uninstallHooks(settings);
|
|
1582
|
+
const hooks = { ...out.hooks ?? {} };
|
|
1583
|
+
for (const [event, group] of Object.entries(raydrHookGroups(cliEntry2, url, nodePath))) {
|
|
1584
|
+
hooks[event] = [...hooks[event] ?? [], group];
|
|
1585
|
+
}
|
|
1586
|
+
out.hooks = hooks;
|
|
1587
|
+
return out;
|
|
1588
|
+
}
|
|
1589
|
+
function installedEvents(settings) {
|
|
1590
|
+
return Object.entries(settings.hooks ?? {}).filter(([, groups]) => groups.some((g) => g.hooks.some(isRaydrHook))).map(([event]) => event);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/lifecycle.ts
|
|
1594
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1595
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1596
|
+
import { connect } from "node:net";
|
|
1597
|
+
import { join as join8 } from "node:path";
|
|
1598
|
+
|
|
1599
|
+
// src/local.ts
|
|
1600
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
1601
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, statfsSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1602
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
1603
|
+
var SPACY_MODEL_URL = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl";
|
|
1604
|
+
var TORCH_CPU_INDEX = "https://download.pytorch.org/whl/cpu";
|
|
1605
|
+
var PACKAGES = ["kokoro", "lameenc", "faster-whisper", "numpy"];
|
|
1606
|
+
var IMPORTS = ["kokoro", "lameenc", "faster_whisper", "numpy", "en_core_web_sm"];
|
|
1607
|
+
var MIN_FREE_BYTES = 3 * 1024 * 1024 * 1024;
|
|
1608
|
+
function sidecarScript(cliEntry2) {
|
|
1609
|
+
return join7(dirname4(cliEntry2), "..", "local", "raydr_local.py");
|
|
1610
|
+
}
|
|
1611
|
+
function sidecarEnv(cfg) {
|
|
1612
|
+
return {
|
|
1613
|
+
...process.env,
|
|
1614
|
+
HF_HOME: join7(cfg.localDir, "models"),
|
|
1615
|
+
HF_HUB_DISABLE_PROGRESS_BARS: "1",
|
|
1616
|
+
RAYDR_KOKORO_VOICE: cfg.kokoroVoice,
|
|
1617
|
+
RAYDR_WHISPER_MODEL: cfg.whisperModel,
|
|
1618
|
+
RAYDR_LOCAL_PORT: String(cfg.localPort),
|
|
1619
|
+
// The sidecar requires this on every POST; it travels in the environment, never argv.
|
|
1620
|
+
RAYDR_ACCESS_KEY: cfg.accessKey
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function readSetup(cfg) {
|
|
1624
|
+
try {
|
|
1625
|
+
return JSON.parse(readFileSync4(localSetupMarker(cfg), "utf8"));
|
|
1626
|
+
} catch {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
function ok(cmd, args, env = process.env) {
|
|
1631
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", env, timeout: 6e5 });
|
|
1632
|
+
if (r.error && r.error.code === "ETIMEDOUT") {
|
|
1633
|
+
return {
|
|
1634
|
+
ok: false,
|
|
1635
|
+
out: `${cmd} ${args[0] ?? ""} timed out after 10 minutes; re-running raydr local setup resumes where it stopped`
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
1639
|
+
}
|
|
1640
|
+
function freeBytes(dir) {
|
|
1641
|
+
mkdirSync4(dir, { recursive: true });
|
|
1642
|
+
const fs = statfsSync(dir);
|
|
1643
|
+
return Number(fs.bavail) * Number(fs.bsize);
|
|
1644
|
+
}
|
|
1645
|
+
function pythonVersion(bin) {
|
|
1646
|
+
const r = ok(bin, ["-c", "import sys;print(sys.version_info[0],sys.version_info[1])"]);
|
|
1647
|
+
if (!r.ok) return null;
|
|
1648
|
+
const [maj, min] = r.out.split(/\s+/).map(Number);
|
|
1649
|
+
return maj !== void 0 && min !== void 0 ? [maj, min] : null;
|
|
1650
|
+
}
|
|
1651
|
+
function findPython(cfg, log2) {
|
|
1652
|
+
if (ok("uv", ["--version"]).ok) {
|
|
1653
|
+
log2("[ ok ] uv on PATH; it provides Python 3.12 for the sidecar");
|
|
1654
|
+
return { kind: "uv", bin: "uv" };
|
|
1655
|
+
}
|
|
1656
|
+
for (const candidate of ["python3.13", "python3.12", "python3.11", "python3.10", "python3"]) {
|
|
1657
|
+
const v = pythonVersion(candidate);
|
|
1658
|
+
if (v && v[0] === 3 && v[1] >= 10 && v[1] <= 13) {
|
|
1659
|
+
log2(`[ ok ] ${candidate} is ${v[0]}.${v[1]}`);
|
|
1660
|
+
return { kind: "system", bin: candidate };
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const sys = pythonVersion("python3");
|
|
1664
|
+
throw new Error(
|
|
1665
|
+
`no usable Python: the sidecar needs 3.10\u20133.13 (python3 is ${sys ? sys.join(".") : "missing"}) and Kokoro's dependencies have no wheels for newer versions. Install uv (curl -LsSf https://astral.sh/uv/install.sh | sh) and run raydr local setup again`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
async function localSetup(cfg, cliEntry2, opts = {}) {
|
|
1669
|
+
const log2 = opts.log ?? ((line) => console.log(line));
|
|
1670
|
+
const env = sidecarEnv(cfg);
|
|
1671
|
+
mkdirSync4(cfg.localDir, { recursive: true });
|
|
1672
|
+
const free = freeBytes(cfg.localDir);
|
|
1673
|
+
if (free < MIN_FREE_BYTES) {
|
|
1674
|
+
throw new Error(
|
|
1675
|
+
`only ${(free / 1024 ** 3).toFixed(1)} GB free on the filesystem holding ${cfg.localDir}; the sidecar needs about 3 GB (venv 1.5 GB, models 0.8 GB, downloads). Free space or set RAYDR_LOCAL_DIR`
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
const py = findPython(cfg, log2);
|
|
1679
|
+
if (ok("espeak-ng", ["--version"]).ok) log2("[ ok ] espeak-ng on PATH");
|
|
1680
|
+
else
|
|
1681
|
+
log2(
|
|
1682
|
+
"[info] espeak-ng not on PATH; the bundled copy is used (apt install espeak-ng / brew install espeak-ng if speech skips words)"
|
|
1683
|
+
);
|
|
1684
|
+
const python = localPython(cfg);
|
|
1685
|
+
if (existsSync6(python)) {
|
|
1686
|
+
log2(`[ ok ] venv at ${join7(cfg.localDir, "venv")}`);
|
|
1687
|
+
} else {
|
|
1688
|
+
log2(`[....] creating venv at ${join7(cfg.localDir, "venv")}`);
|
|
1689
|
+
const r2 = py.kind === "uv" ? ok("uv", ["venv", "--python", "3.12", join7(cfg.localDir, "venv")], env) : ok(py.bin, ["-m", "venv", join7(cfg.localDir, "venv")], env);
|
|
1690
|
+
if (!r2.ok) throw new Error(`could not create the venv: ${r2.out.slice(-300)}`);
|
|
1691
|
+
log2("[ ok ] venv created");
|
|
1692
|
+
}
|
|
1693
|
+
const missing = IMPORTS.filter((m) => !ok(python, ["-c", `import ${m}`], env).ok);
|
|
1694
|
+
if (missing.length === 0) {
|
|
1695
|
+
log2("[ ok ] packages present (kokoro, faster-whisper, lameenc, numpy, spaCy English)");
|
|
1696
|
+
} else {
|
|
1697
|
+
log2(`[....] installing ${missing.join(", ")} (CPU torch about 200 MB, the rest about 300 MB)`);
|
|
1698
|
+
const pip = (args) => py.kind === "uv" ? ok("uv", ["pip", "install", "--python", python, ...args], env) : ok(python, ["-m", "pip", "install", ...args], env);
|
|
1699
|
+
const previous = readSetup(cfg)?.torch;
|
|
1700
|
+
const torch = pip([previous ? `torch==${previous}` : "torch", "--index-url", TORCH_CPU_INDEX]);
|
|
1701
|
+
if (!torch.ok) throw new Error(`torch install failed: ${torch.out.slice(-300)}`);
|
|
1702
|
+
const pinned = torchVersion(python, env);
|
|
1703
|
+
const rest = pip([...PACKAGES, SPACY_MODEL_URL, ...pinned ? [`torch==${pinned}`] : []]);
|
|
1704
|
+
if (!rest.ok) throw new Error(`package install failed: ${rest.out.slice(-300)}`);
|
|
1705
|
+
log2("[ ok ] packages installed");
|
|
1706
|
+
}
|
|
1707
|
+
const script = sidecarScript(cliEntry2);
|
|
1708
|
+
const hub = join7(cfg.localDir, "models", "hub");
|
|
1709
|
+
const have = (name) => existsSync6(join7(hub, name, "snapshots"));
|
|
1710
|
+
const need = [
|
|
1711
|
+
...have("models--hexgrad--Kokoro-82M") ? [] : ["Kokoro-82M about 330 MB"],
|
|
1712
|
+
...have(`models--Systran--faster-whisper-${cfg.whisperModel}`) ? [] : [
|
|
1713
|
+
`faster-whisper ${cfg.whisperModel} about ${cfg.whisperModel.startsWith("small") ? "460" : cfg.whisperModel.startsWith("base") ? "150" : "500+"} MB`
|
|
1714
|
+
]
|
|
1715
|
+
];
|
|
1716
|
+
log2(need.length ? `[....] downloading ${need.join(" and ")}` : "[....] verifying model caches");
|
|
1717
|
+
const r = ok(python, [script, "--download"], env);
|
|
1718
|
+
if (!r.ok) throw new Error(`model download failed: ${r.out.slice(-300)}`);
|
|
1719
|
+
log2(
|
|
1720
|
+
need.length ? "[ ok ] models downloaded" : `[ ok ] models verified under ${join7(cfg.localDir, "models")}`
|
|
1721
|
+
);
|
|
1722
|
+
const espeak = ok(python, [script, "--probe-espeak"], env);
|
|
1723
|
+
if (espeak.ok) log2("[ ok ] espeak-ng phonemizer works");
|
|
1724
|
+
else
|
|
1725
|
+
log2(
|
|
1726
|
+
"[warn] bundled espeak-ng cannot load its data; Kokoro will skip words it has no pronunciation for"
|
|
1727
|
+
);
|
|
1728
|
+
const record = {
|
|
1729
|
+
python,
|
|
1730
|
+
noEspeak: !espeak.ok,
|
|
1731
|
+
whisperModel: cfg.whisperModel,
|
|
1732
|
+
kokoroVoice: cfg.kokoroVoice,
|
|
1733
|
+
torch: torchVersion(python, env) ?? readSetup(cfg)?.torch ?? "",
|
|
1734
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1735
|
+
};
|
|
1736
|
+
writeFileSync3(localSetupMarker(cfg), `${JSON.stringify(record, null, 2)}
|
|
1737
|
+
`);
|
|
1738
|
+
log2(`[ ok ] setup recorded at ${localSetupMarker(cfg)}`);
|
|
1739
|
+
return record;
|
|
1740
|
+
}
|
|
1741
|
+
function torchVersion(python, env) {
|
|
1742
|
+
const r = ok(python, ["-c", "import torch;print(torch.__version__)"], env);
|
|
1743
|
+
return r.ok ? r.out.split("\n").pop()?.trim() || null : null;
|
|
1744
|
+
}
|
|
1745
|
+
function sidecarArgs(cfg, cliEntry2, extra = []) {
|
|
1746
|
+
const rec = readSetup(cfg);
|
|
1747
|
+
return [
|
|
1748
|
+
sidecarScript(cliEntry2),
|
|
1749
|
+
"--port",
|
|
1750
|
+
String(cfg.localPort),
|
|
1751
|
+
"--voice",
|
|
1752
|
+
cfg.kokoroVoice,
|
|
1753
|
+
"--model",
|
|
1754
|
+
cfg.whisperModel,
|
|
1755
|
+
...rec?.noEspeak ? ["--no-espeak"] : [],
|
|
1756
|
+
...extra
|
|
1757
|
+
];
|
|
1758
|
+
}
|
|
1759
|
+
function spawnSidecarInline(cfg, cliEntry2) {
|
|
1760
|
+
const child = spawn(localPython(cfg), sidecarArgs(cfg, cliEntry2, ["--warm"]), {
|
|
1761
|
+
env: sidecarEnv(cfg),
|
|
1762
|
+
stdio: "inherit"
|
|
1763
|
+
});
|
|
1764
|
+
child.on("error", (err) => console.error(`local sidecar failed to start: ${err.message}`));
|
|
1765
|
+
child.on("exit", (code, signal) => {
|
|
1766
|
+
if (code !== 0)
|
|
1767
|
+
console.error(
|
|
1768
|
+
`local sidecar exited (${signal ?? `code ${code}`}); speech and transcription are off until raydr is restarted`
|
|
1769
|
+
);
|
|
1770
|
+
});
|
|
1771
|
+
return child;
|
|
1772
|
+
}
|
|
1773
|
+
async function sidecarHealth(cfg) {
|
|
1774
|
+
try {
|
|
1775
|
+
const res = await fetch(`http://127.0.0.1:${cfg.localPort}/healthz`, {
|
|
1776
|
+
signal: AbortSignal.timeout(1500)
|
|
1777
|
+
});
|
|
1778
|
+
return res.ok ? await res.json() : null;
|
|
1779
|
+
} catch {
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/lifecycle.ts
|
|
1785
|
+
function pidFile(cfg) {
|
|
1786
|
+
return join8(cfg.stateDir, "server.pid");
|
|
1787
|
+
}
|
|
1788
|
+
function logFile(cfg) {
|
|
1789
|
+
return join8(cfg.stateDir, "server.log");
|
|
1790
|
+
}
|
|
1791
|
+
function localPidFile(cfg) {
|
|
1792
|
+
return join8(cfg.stateDir, "local.pid");
|
|
1793
|
+
}
|
|
1794
|
+
function localLogFile(cfg) {
|
|
1795
|
+
return join8(cfg.stateDir, "local.log");
|
|
1796
|
+
}
|
|
1797
|
+
function readLocalPid(cfg) {
|
|
1798
|
+
try {
|
|
1799
|
+
const pid = Number(readFileSync5(localPidFile(cfg), "utf8").trim());
|
|
1800
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1801
|
+
} catch {
|
|
1802
|
+
return null;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
function isSidecarPid(pid, procRoot = "/proc") {
|
|
1806
|
+
if (!alive(pid)) return "stale";
|
|
1807
|
+
try {
|
|
1808
|
+
const argv = readFileSync5(`${procRoot}/${pid}/cmdline`, "utf8").split("\0");
|
|
1809
|
+
return argv.some((a) => a.endsWith("raydr_local.py")) ? "raydr" : "stale";
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
return err.code === "ENOENT" ? "unverifiable" : "stale";
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async function startSidecar(cfg, cliEntry2) {
|
|
1815
|
+
if (!usesLocal(cfg)) return null;
|
|
1816
|
+
if (!localReady(cfg)) return "local provider selected but not set up; run raydr local setup";
|
|
1817
|
+
if (await sidecarHealth(cfg))
|
|
1818
|
+
return `local sidecar already running on http://127.0.0.1:${cfg.localPort}`;
|
|
1819
|
+
await requireLocalPortFree(cfg);
|
|
1820
|
+
mkdirSync5(cfg.stateDir, { recursive: true });
|
|
1821
|
+
const log2 = openSync(localLogFile(cfg), "a");
|
|
1822
|
+
const child = spawn2(localPython(cfg), sidecarArgs(cfg, cliEntry2, ["--warm"]), {
|
|
1823
|
+
detached: true,
|
|
1824
|
+
stdio: ["ignore", log2, log2],
|
|
1825
|
+
env: sidecarEnv(cfg)
|
|
1826
|
+
});
|
|
1827
|
+
child.unref();
|
|
1828
|
+
if (child.pid === void 0) return "could not spawn the local sidecar";
|
|
1829
|
+
writeFileSync4(localPidFile(cfg), `${child.pid}
|
|
1830
|
+
`);
|
|
1831
|
+
return `local sidecar started (pid ${child.pid}) on http://127.0.0.1:${cfg.localPort}; models load in the background`;
|
|
1832
|
+
}
|
|
1833
|
+
async function stopSidecar(cfg) {
|
|
1834
|
+
const pid = readLocalPid(cfg);
|
|
1835
|
+
if (pid === null) return null;
|
|
1836
|
+
const verdict = isSidecarPid(pid);
|
|
1837
|
+
if (verdict === "unverifiable")
|
|
1838
|
+
return `cannot verify that pid ${pid} is the sidecar (no /proc); stop it manually`;
|
|
1839
|
+
if (verdict === "stale") {
|
|
1840
|
+
if (existsSync7(localPidFile(cfg))) unlinkSync2(localPidFile(cfg));
|
|
1841
|
+
return "local sidecar pid file was stale; removed it";
|
|
1842
|
+
}
|
|
1843
|
+
process.kill(pid, "SIGTERM");
|
|
1844
|
+
const deadline = Date.now() + 5e3;
|
|
1845
|
+
while (Date.now() < deadline) {
|
|
1846
|
+
await sleep2(100);
|
|
1847
|
+
if (!alive(pid)) {
|
|
1848
|
+
unlinkSync2(localPidFile(cfg));
|
|
1849
|
+
return `local sidecar stopped (pid ${pid})`;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return `sidecar pid ${pid} did not exit within 5 s; kill it manually`;
|
|
1853
|
+
}
|
|
1854
|
+
function readPid(cfg) {
|
|
1855
|
+
try {
|
|
1856
|
+
const pid = Number(readFileSync5(pidFile(cfg), "utf8").trim());
|
|
1857
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1858
|
+
} catch {
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
function alive(pid) {
|
|
1863
|
+
try {
|
|
1864
|
+
process.kill(pid, 0);
|
|
1865
|
+
return true;
|
|
1866
|
+
} catch {
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
async function isRaydrPid(pid, cfg, cliEntry2, procRoot = "/proc") {
|
|
1871
|
+
if (!alive(pid)) return "stale";
|
|
1872
|
+
const h = await health(cfg);
|
|
1873
|
+
if (h?.pid === pid) return "raydr";
|
|
1874
|
+
try {
|
|
1875
|
+
const argv = readFileSync5(`${procRoot}/${pid}/cmdline`, "utf8").split("\0");
|
|
1876
|
+
return argv.includes(cliEntry2) && argv.includes("serve") ? "raydr" : "stale";
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
return err.code === "ENOENT" ? "unverifiable" : "stale";
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
async function requireLocalPortFree(cfg) {
|
|
1882
|
+
if (await portBound({ ...cfg, host: "127.0.0.1", port: cfg.localPort })) {
|
|
1883
|
+
throw new Error(
|
|
1884
|
+
`port ${cfg.localPort} is in use by another program; set RAYDR_LOCAL_PORT to move the sidecar`
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
function portBound(cfg) {
|
|
1889
|
+
return new Promise((resolve4) => {
|
|
1890
|
+
const socket = connect({ host: cfg.host, port: cfg.port });
|
|
1891
|
+
socket.setTimeout(1e3);
|
|
1892
|
+
socket.once("connect", () => {
|
|
1893
|
+
socket.destroy();
|
|
1894
|
+
resolve4(true);
|
|
1895
|
+
});
|
|
1896
|
+
socket.once("error", () => resolve4(false));
|
|
1897
|
+
socket.once("timeout", () => {
|
|
1898
|
+
socket.destroy();
|
|
1899
|
+
resolve4(false);
|
|
1900
|
+
});
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
async function health(cfg) {
|
|
1904
|
+
try {
|
|
1905
|
+
const res = await fetch(`http://${cfg.host}:${cfg.port}/healthz`, {
|
|
1906
|
+
signal: AbortSignal.timeout(1500)
|
|
1907
|
+
});
|
|
1908
|
+
if (!res.ok) return null;
|
|
1909
|
+
const body = await res.json();
|
|
1910
|
+
return body.ok ? body : null;
|
|
1911
|
+
} catch {
|
|
1912
|
+
return null;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1916
|
+
async function start(cfg, cliEntry2, opts = {}) {
|
|
1917
|
+
if (await portBound(cfg)) {
|
|
1918
|
+
const h = await health(cfg);
|
|
1919
|
+
if (h)
|
|
1920
|
+
throw new Error(`raydr is already running on port ${cfg.port} (pid ${h.pid ?? "unknown"})`);
|
|
1921
|
+
throw new Error(`port ${cfg.port} is in use by another program; set RAYDR_PORT to move raydr`);
|
|
1922
|
+
}
|
|
1923
|
+
mkdirSync5(cfg.stateDir, { recursive: true });
|
|
1924
|
+
const child = opts.spawnServer ? opts.spawnServer() : spawnServer(cfg, cliEntry2, opts.serveArgs ?? []);
|
|
1925
|
+
if (child.pid === void 0) throw new Error("could not spawn the server");
|
|
1926
|
+
writeFileSync4(pidFile(cfg), `${child.pid}
|
|
1927
|
+
`);
|
|
1928
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 1e4);
|
|
1929
|
+
while (Date.now() < deadline) {
|
|
1930
|
+
await sleep2(Math.min(200, opts.timeoutMs ?? 200));
|
|
1931
|
+
const h = await health(cfg);
|
|
1932
|
+
if (h)
|
|
1933
|
+
return `raydr started (pid ${child.pid}) on http://${cfg.host}:${cfg.port}
|
|
1934
|
+
log: ${logFile(cfg)}`;
|
|
1935
|
+
if (!alive(child.pid)) {
|
|
1936
|
+
unlinkSync2(pidFile(cfg));
|
|
1937
|
+
throw new Error(`the server exited during startup; see ${logFile(cfg)}`);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return `raydr is still starting (pid ${child.pid}); check \`raydr status\`
|
|
1941
|
+
log: ${logFile(cfg)}`;
|
|
1942
|
+
}
|
|
1943
|
+
function spawnServer(cfg, cliEntry2, serveArgs) {
|
|
1944
|
+
const log2 = openSync(logFile(cfg), "a");
|
|
1945
|
+
const child = spawn2(process.execPath, [cliEntry2, "serve", ...serveArgs], {
|
|
1946
|
+
detached: true,
|
|
1947
|
+
stdio: ["ignore", log2, log2],
|
|
1948
|
+
env: process.env
|
|
1949
|
+
});
|
|
1950
|
+
child.unref();
|
|
1951
|
+
return { pid: child.pid };
|
|
1952
|
+
}
|
|
1953
|
+
async function stop(cfg, cliEntry2, procRoot = "/proc") {
|
|
1954
|
+
const pid = readPid(cfg);
|
|
1955
|
+
if (pid === null) {
|
|
1956
|
+
const h = await health(cfg);
|
|
1957
|
+
if (h?.pid)
|
|
1958
|
+
throw new Error(
|
|
1959
|
+
`raydr (pid ${h.pid}) was not started by \`raydr start\`; stop it where it runs`
|
|
1960
|
+
);
|
|
1961
|
+
return "raydr is not running";
|
|
1962
|
+
}
|
|
1963
|
+
const verdict = await isRaydrPid(pid, cfg, cliEntry2, procRoot);
|
|
1964
|
+
if (verdict === "unverifiable") {
|
|
1965
|
+
throw new Error(`cannot verify that pid ${pid} is raydr (no /proc); stop it manually`);
|
|
1966
|
+
}
|
|
1967
|
+
if (verdict === "stale") {
|
|
1968
|
+
if (existsSync7(pidFile(cfg))) unlinkSync2(pidFile(cfg));
|
|
1969
|
+
return `pid file was stale (pid ${pid} is not a raydr server); removed it`;
|
|
1970
|
+
}
|
|
1971
|
+
process.kill(pid, "SIGTERM");
|
|
1972
|
+
const deadline = Date.now() + 5e3;
|
|
1973
|
+
while (Date.now() < deadline) {
|
|
1974
|
+
await sleep2(100);
|
|
1975
|
+
if (!alive(pid)) {
|
|
1976
|
+
unlinkSync2(pidFile(cfg));
|
|
1977
|
+
return `raydr stopped (pid ${pid})`;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
throw new Error(`pid ${pid} did not exit within 5 s; kill it manually`);
|
|
1981
|
+
}
|
|
1982
|
+
function formatUptime(seconds) {
|
|
1983
|
+
const h = Math.floor(seconds / 3600);
|
|
1984
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1985
|
+
const s = seconds % 60;
|
|
1986
|
+
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
1987
|
+
}
|
|
1988
|
+
function tailLog(cfg, lines = 50) {
|
|
1989
|
+
try {
|
|
1990
|
+
const all = readFileSync5(logFile(cfg), "utf8").split("\n");
|
|
1991
|
+
if (all.at(-1) === "") all.pop();
|
|
1992
|
+
return all.slice(-lines).join("\n");
|
|
1993
|
+
} catch {
|
|
1994
|
+
return `(no log yet at ${logFile(cfg)})`;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/doctor.ts
|
|
1999
|
+
async function doctor(cfg, cliEntry2, offline) {
|
|
2000
|
+
const checks = [];
|
|
2001
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
2002
|
+
checks.push(
|
|
2003
|
+
major >= 24 ? { level: "ok", name: "node", detail: `v${process.versions.node}` } : {
|
|
2004
|
+
level: "fail",
|
|
2005
|
+
name: "node",
|
|
2006
|
+
detail: `v${process.versions.node}; raydr needs 24 or newer`
|
|
2007
|
+
}
|
|
2008
|
+
);
|
|
2009
|
+
checks.push({
|
|
2010
|
+
level: "ok",
|
|
2011
|
+
name: "raydr",
|
|
2012
|
+
detail: `v${VERSION}, ${/[\\/]node_modules[\\/](@tensorkit[\\/])?raydr[\\/]/.test(cliEntry2) ? "npm package" : "linked checkout"} at ${cliEntry2}`
|
|
2013
|
+
});
|
|
2014
|
+
checks.push(await herdrCheck());
|
|
2015
|
+
if (!existsSync8(cfg.envFile)) {
|
|
2016
|
+
checks.push({
|
|
2017
|
+
level: "fail",
|
|
2018
|
+
name: "env file",
|
|
2019
|
+
detail: `${cfg.envFile} missing; run raydr init`
|
|
2020
|
+
});
|
|
2021
|
+
} else {
|
|
2022
|
+
const mode = statSync3(cfg.envFile).mode & 511;
|
|
2023
|
+
checks.push(
|
|
2024
|
+
(mode & 63) === 0 ? { level: "ok", name: "env file", detail: `${cfg.envFile} (mode ${mode.toString(8)})` } : {
|
|
2025
|
+
level: "warn",
|
|
2026
|
+
name: "env file",
|
|
2027
|
+
detail: `${cfg.envFile} is mode ${mode.toString(8)}; run chmod 600`
|
|
2028
|
+
}
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
checks.push(...await providerChecks(cfg, offline));
|
|
2032
|
+
checks.push(hooksCheck(cliEntry2));
|
|
2033
|
+
const h = await health(cfg);
|
|
2034
|
+
checks.push(
|
|
2035
|
+
h ? {
|
|
2036
|
+
level: "ok",
|
|
2037
|
+
name: "server",
|
|
2038
|
+
detail: `http://${cfg.host}:${cfg.port} pid ${h.pid ?? "?"}, speech ${h.speech ?? "off"}`
|
|
2039
|
+
} : {
|
|
2040
|
+
level: "warn",
|
|
2041
|
+
name: "server",
|
|
2042
|
+
detail: `nothing on http://${cfg.host}:${cfg.port}; run raydr start (log: ${logFile(cfg)})`
|
|
2043
|
+
}
|
|
2044
|
+
);
|
|
2045
|
+
return checks;
|
|
2046
|
+
}
|
|
2047
|
+
function herdrCheck() {
|
|
2048
|
+
return new Promise((resolve4) => {
|
|
2049
|
+
execFile2("herdr", ["status", "server"], { timeout: 5e3 }, (err, stdout) => {
|
|
2050
|
+
if (err) {
|
|
2051
|
+
const missing = err.code === "ENOENT";
|
|
2052
|
+
resolve4({
|
|
2053
|
+
level: "warn",
|
|
2054
|
+
name: "herdr",
|
|
2055
|
+
detail: missing ? "not on PATH; replies are heard but menus cannot be answered from the app" : `on PATH but not reachable: ${err.message.split("\n")[0]}`
|
|
2056
|
+
});
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
resolve4({ level: "ok", name: "herdr", detail: stdout.trim().split("\n")[0] || "reachable" });
|
|
2060
|
+
});
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
function hooksCheck(cliEntry2) {
|
|
2064
|
+
const files = [
|
|
2065
|
+
join9(homedir2(), ".claude", "settings.json"),
|
|
2066
|
+
resolve2(".claude", "settings.local.json")
|
|
2067
|
+
];
|
|
2068
|
+
const found = [];
|
|
2069
|
+
let stale = false;
|
|
2070
|
+
for (const file of files) {
|
|
2071
|
+
if (!existsSync8(file)) continue;
|
|
2072
|
+
let settings;
|
|
2073
|
+
try {
|
|
2074
|
+
settings = JSON.parse(readFileSync6(file, "utf8"));
|
|
2075
|
+
} catch {
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
const events = installedEvents(settings);
|
|
2079
|
+
if (events.length === 0) continue;
|
|
2080
|
+
found.push(`${file} (${events.join(", ")})`);
|
|
2081
|
+
const commands = Object.values(settings.hooks ?? {}).flatMap((groups) => groups.flatMap((g) => g.hooks)).filter(isRaydrHook).map((h) => h.command ?? "");
|
|
2082
|
+
if (commands.some((c) => !c.includes(cliEntry2))) stale = true;
|
|
2083
|
+
}
|
|
2084
|
+
if (found.length === 0)
|
|
2085
|
+
return { level: "fail", name: "hooks", detail: "not installed; run raydr hooks install" };
|
|
2086
|
+
if (stale)
|
|
2087
|
+
return {
|
|
2088
|
+
level: "warn",
|
|
2089
|
+
name: "hooks",
|
|
2090
|
+
detail: `installed but point at another checkout; re-run raydr hooks install (${found.join("; ")})`
|
|
2091
|
+
};
|
|
2092
|
+
return { level: "ok", name: "hooks", detail: found.join("; ") };
|
|
2093
|
+
}
|
|
2094
|
+
async function elevenLabsCheck(apiKey, voiceId) {
|
|
2095
|
+
try {
|
|
2096
|
+
const res = await fetch(
|
|
2097
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream?output_format=mp3_22050_32`,
|
|
2098
|
+
{
|
|
2099
|
+
method: "POST",
|
|
2100
|
+
headers: { "xi-api-key": apiKey, "content-type": "application/json" },
|
|
2101
|
+
body: JSON.stringify({ text: "hi", model_id: "eleven_flash_v2_5" }),
|
|
2102
|
+
signal: AbortSignal.timeout(1e4)
|
|
2103
|
+
}
|
|
2104
|
+
);
|
|
2105
|
+
if (res.ok) {
|
|
2106
|
+
await res.body?.cancel();
|
|
2107
|
+
return {
|
|
2108
|
+
level: "ok",
|
|
2109
|
+
name: "elevenlabs api",
|
|
2110
|
+
detail: "key accepted (2-character synthesis)"
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
return {
|
|
2114
|
+
level: "fail",
|
|
2115
|
+
name: "elevenlabs api",
|
|
2116
|
+
detail: `HTTP ${res.status} ${(await res.text()).slice(0, 120)}`
|
|
2117
|
+
};
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
return {
|
|
2120
|
+
level: "fail",
|
|
2121
|
+
name: "elevenlabs api",
|
|
2122
|
+
detail: `unreachable: ${err.message}`
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
function formatChecks(checks) {
|
|
2127
|
+
const mark = { ok: " ok ", warn: "warn", fail: "FAIL" };
|
|
2128
|
+
return checks.map((c) => `[${mark[c.level]}] ${c.name.padEnd(15)} ${c.detail}`).join("\n");
|
|
2129
|
+
}
|
|
2130
|
+
async function providerChecks(cfg, offline) {
|
|
2131
|
+
const out = [];
|
|
2132
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2133
|
+
for (const [kind, r] of [
|
|
2134
|
+
["speech", speechReadiness(cfg)],
|
|
2135
|
+
["transcription", transcriptionReadiness(cfg)]
|
|
2136
|
+
]) {
|
|
2137
|
+
if (!r.ready) {
|
|
2138
|
+
out.push({
|
|
2139
|
+
level: "fail",
|
|
2140
|
+
name: kind,
|
|
2141
|
+
detail: `${r.provider}: missing ${r.missing}; run ${r.fix}`
|
|
2142
|
+
});
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
out.push({ level: "ok", name: kind, detail: r.provider });
|
|
2146
|
+
const backend = r.provider === "kokoro" || r.provider === "whisper" ? "local" : r.provider;
|
|
2147
|
+
if (seen.has(backend)) continue;
|
|
2148
|
+
seen.add(backend);
|
|
2149
|
+
if (r.provider === "elevenlabs") {
|
|
2150
|
+
out.push(
|
|
2151
|
+
offline ? skipped("elevenlabs api") : await elevenLabsCheck(cfg.elevenLabsApiKey ?? "", cfg.elevenLabsVoiceId)
|
|
2152
|
+
);
|
|
2153
|
+
} else if (r.provider === "openai") {
|
|
2154
|
+
out.push(
|
|
2155
|
+
offline ? skipped("openai api") : await openAiCheck(cfg.openAiApiKey ?? "", cfg.openAiVoice)
|
|
2156
|
+
);
|
|
2157
|
+
} else {
|
|
2158
|
+
out.push(await localCheck(cfg));
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return out;
|
|
2162
|
+
}
|
|
2163
|
+
function skipped(name) {
|
|
2164
|
+
return { level: "ok", name, detail: "skipped (--offline)" };
|
|
2165
|
+
}
|
|
2166
|
+
async function localCheck(cfg) {
|
|
2167
|
+
if (!localReady(cfg))
|
|
2168
|
+
return { level: "fail", name: "local models", detail: "not set up; run raydr local setup" };
|
|
2169
|
+
const rec = readSetup(cfg);
|
|
2170
|
+
const h = await sidecarHealth(cfg);
|
|
2171
|
+
const models = `models under ${cfg.localDir}${rec?.noEspeak ? ", espeak fallback off" : ""}`;
|
|
2172
|
+
return h ? {
|
|
2173
|
+
level: "ok",
|
|
2174
|
+
name: "local sidecar",
|
|
2175
|
+
detail: `answering on http://127.0.0.1:${cfg.localPort} (${h.tts && h.stt ? "models loaded" : "models loading"}); ${models}`
|
|
2176
|
+
} : {
|
|
2177
|
+
level: "warn",
|
|
2178
|
+
name: "local sidecar",
|
|
2179
|
+
detail: `not answering on http://127.0.0.1:${cfg.localPort}; raydr start launches it; ${models}`
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
async function openAiCheck(apiKey, voice) {
|
|
2183
|
+
try {
|
|
2184
|
+
const res = await fetch("https://api.openai.com/v1/audio/speech", {
|
|
2185
|
+
method: "POST",
|
|
2186
|
+
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
2187
|
+
body: JSON.stringify({
|
|
2188
|
+
model: "gpt-4o-mini-tts",
|
|
2189
|
+
voice,
|
|
2190
|
+
input: "hi",
|
|
2191
|
+
response_format: "mp3"
|
|
2192
|
+
}),
|
|
2193
|
+
signal: AbortSignal.timeout(15e3)
|
|
2194
|
+
});
|
|
2195
|
+
if (res.ok) {
|
|
2196
|
+
await res.body?.cancel();
|
|
2197
|
+
return { level: "ok", name: "openai api", detail: "key accepted (2-character synthesis)" };
|
|
2198
|
+
}
|
|
2199
|
+
return {
|
|
2200
|
+
level: "fail",
|
|
2201
|
+
name: "openai api",
|
|
2202
|
+
detail: `HTTP ${res.status} ${(await res.text()).slice(0, 120)}`
|
|
2203
|
+
};
|
|
2204
|
+
} catch (err) {
|
|
2205
|
+
return { level: "fail", name: "openai api", detail: `unreachable: ${err.message}` };
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/init.ts
|
|
2210
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync7, renameSync as renameSync2, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2211
|
+
import { dirname as dirname5 } from "node:path";
|
|
2212
|
+
import { createInterface } from "node:readline/promises";
|
|
2213
|
+
import { Writable } from "node:stream";
|
|
2214
|
+
var pick = (value, allowed) => value && allowed.includes(value) ? value : null;
|
|
2215
|
+
async function init(opts) {
|
|
2216
|
+
const envFile = defaultEnvFile();
|
|
2217
|
+
const existing = existsSync9(envFile) ? withLegacyNames(parseEnvFile(readFileSync7(envFile, "utf8"))) : {};
|
|
2218
|
+
const initialised = Object.keys(existing).some((k) => k !== "RAYDR_ACCESS_KEY");
|
|
2219
|
+
if (initialised && !opts.force) {
|
|
2220
|
+
throw new Error(`${envFile} already exists; re-run with --force to replace it`);
|
|
2221
|
+
}
|
|
2222
|
+
const interactive = process.stdin.isTTY === true;
|
|
2223
|
+
if (opts.speech !== void 0 && !pick(opts.speech, SPEECH_PROVIDERS)) {
|
|
2224
|
+
throw new Error(`--speech must be one of ${SPEECH_PROVIDERS.join(", ")}`);
|
|
2225
|
+
}
|
|
2226
|
+
if (opts.transcription !== void 0 && !pick(opts.transcription, TRANSCRIPTION_PROVIDERS)) {
|
|
2227
|
+
throw new Error(`--transcription must be one of ${TRANSCRIPTION_PROVIDERS.join(", ")}`);
|
|
2228
|
+
}
|
|
2229
|
+
const speech = pick(opts.speech, SPEECH_PROVIDERS) ?? (interactive ? await choose("Speech provider", SPEECH_PROVIDERS) : "elevenlabs");
|
|
2230
|
+
const transcription = pick(opts.transcription, TRANSCRIPTION_PROVIDERS) ?? (interactive ? await choose("Transcription provider", TRANSCRIPTION_PROVIDERS) : "elevenlabs");
|
|
2231
|
+
const needsElevenLabs = speech === "elevenlabs" || transcription === "elevenlabs";
|
|
2232
|
+
const needsOpenAi = speech === "openai" || transcription === "openai";
|
|
2233
|
+
const keep = (name) => opts.force ? existing[name] : void 0;
|
|
2234
|
+
const elevenLabsKey = opts.elevenLabsKey ?? keep("ELEVENLABS_API_KEY") ?? (needsElevenLabs && interactive ? await ask("ElevenLabs API key (hidden): ", true) : "");
|
|
2235
|
+
const openAiKey = opts.openAiKey ?? keep("OPENAI_API_KEY") ?? (needsOpenAi && interactive ? await ask("OpenAI API key (hidden): ", true) : "");
|
|
2236
|
+
const voice = opts.voiceId ?? keep("ELEVENLABS_VOICE_ID") ?? (speech === "elevenlabs" && interactive ? await ask("ElevenLabs voice id (blank for default): ") : "");
|
|
2237
|
+
const port = opts.port ?? keep("RAYDR_PORT") ?? (interactive ? await ask(`Port (blank for ${DEFAULT_PORT}): `) : "");
|
|
2238
|
+
if (port && !/^\d{2,5}$/.test(port)) throw new Error(`"${port}" is not a port number`);
|
|
2239
|
+
const accessKey = !opts.rotateKey && existing.RAYDR_ACCESS_KEY ? existing.RAYDR_ACCESS_KEY : newAccessKey();
|
|
2240
|
+
const lines = [
|
|
2241
|
+
"# raydr configuration; read by the server and the Claude Code hooks",
|
|
2242
|
+
`RAYDR_SPEECH_PROVIDER=${speech}`,
|
|
2243
|
+
`RAYDR_TRANSCRIPTION_PROVIDER=${transcription}`,
|
|
2244
|
+
`ELEVENLABS_API_KEY=${elevenLabsKey ?? ""}`,
|
|
2245
|
+
`ELEVENLABS_VOICE_ID=${voice ?? ""}`,
|
|
2246
|
+
`OPENAI_API_KEY=${openAiKey ?? ""}`,
|
|
2247
|
+
...port ? [`RAYDR_PORT=${port}`] : [],
|
|
2248
|
+
// Anything else the previous file had (RAYDR_SESSIONS, local options) survives a --force.
|
|
2249
|
+
...Object.entries(existing).filter(([k]) => !KNOWN.has(k)).map(([k, v]) => `${k}=${v}`),
|
|
2250
|
+
`RAYDR_ACCESS_KEY=${accessKey}`,
|
|
2251
|
+
""
|
|
2252
|
+
];
|
|
2253
|
+
mkdirSync6(dirname5(envFile), { recursive: true, mode: 448 });
|
|
2254
|
+
const tmp = `${envFile}.tmp-${process.pid}`;
|
|
2255
|
+
writeFileSync5(tmp, lines.join("\n"), { mode: 384, flag: "wx" });
|
|
2256
|
+
renameSync2(tmp, envFile);
|
|
2257
|
+
const local = speech === "kokoro" || transcription === "whisper";
|
|
2258
|
+
let localSetupPending = local;
|
|
2259
|
+
if (local && opts.runLocalSetup) localSetupPending = !await opts.runLocalSetup();
|
|
2260
|
+
const notes = [
|
|
2261
|
+
`wrote ${envFile} (mode 600): speech ${speech}, transcription ${transcription}`,
|
|
2262
|
+
needsElevenLabs && !elevenLabsKey ? "no ElevenLabs key: add ELEVENLABS_API_KEY before starting" : "",
|
|
2263
|
+
needsOpenAi && !openAiKey ? "no OpenAI key: add OPENAI_API_KEY before starting" : "",
|
|
2264
|
+
localSetupPending ? "local models: run raydr local setup before starting" : "",
|
|
2265
|
+
opts.rotateKey ? "access key rotated; the web app will ask for the new one" : "",
|
|
2266
|
+
"next: raydr hooks install, then raydr start"
|
|
2267
|
+
];
|
|
2268
|
+
return {
|
|
2269
|
+
envFile,
|
|
2270
|
+
speech,
|
|
2271
|
+
transcription,
|
|
2272
|
+
localSetupPending,
|
|
2273
|
+
message: notes.filter(Boolean).join("\n")
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function withLegacyNames(env) {
|
|
2277
|
+
const out = {};
|
|
2278
|
+
for (const [k, v] of Object.entries(env)) {
|
|
2279
|
+
if (k.startsWith("RAYDAR_")) {
|
|
2280
|
+
const key = `RAYDR_${k.slice("RAYDAR_".length)}`;
|
|
2281
|
+
if (env[key] === void 0) out[key] = v;
|
|
2282
|
+
} else out[k] = v;
|
|
2283
|
+
}
|
|
2284
|
+
return out;
|
|
2285
|
+
}
|
|
2286
|
+
var KNOWN = /* @__PURE__ */ new Set([
|
|
2287
|
+
"RAYDR_SPEECH_PROVIDER",
|
|
2288
|
+
"RAYDR_TRANSCRIPTION_PROVIDER",
|
|
2289
|
+
"ELEVENLABS_API_KEY",
|
|
2290
|
+
"ELEVENLABS_VOICE_ID",
|
|
2291
|
+
"OPENAI_API_KEY",
|
|
2292
|
+
"RAYDR_PORT",
|
|
2293
|
+
"RAYDR_ACCESS_KEY"
|
|
2294
|
+
]);
|
|
2295
|
+
async function choose(label, allowed) {
|
|
2296
|
+
for (; ; ) {
|
|
2297
|
+
const answer = await ask(`${label} (${allowed.join(" / ")}) [${allowed[0]}]: `);
|
|
2298
|
+
if (answer === "") return allowed[0];
|
|
2299
|
+
const picked = pick(answer, allowed);
|
|
2300
|
+
if (picked) return picked;
|
|
2301
|
+
process.stdout.write(` "${answer}" is not one of ${allowed.join(", ")}
|
|
2302
|
+
`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
async function ask(question, hidden = false) {
|
|
2306
|
+
const muted = new Writable({ write: (_chunk, _enc, cb) => cb() });
|
|
2307
|
+
const rl = createInterface({
|
|
2308
|
+
input: process.stdin,
|
|
2309
|
+
output: hidden ? muted : process.stdout,
|
|
2310
|
+
terminal: true
|
|
2311
|
+
});
|
|
2312
|
+
if (hidden) process.stdout.write(question);
|
|
2313
|
+
try {
|
|
2314
|
+
const answer = await rl.question(hidden ? "" : question);
|
|
2315
|
+
if (hidden) process.stdout.write("\n");
|
|
2316
|
+
return answer.trim();
|
|
2317
|
+
} finally {
|
|
2318
|
+
rl.close();
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
async function confirm(question) {
|
|
2322
|
+
const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
|
|
2323
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// src/session.ts
|
|
2327
|
+
import { basename as basename2 } from "node:path";
|
|
2328
|
+
async function session(verb, cfg, env, cwd = env.PWD ?? process.cwd()) {
|
|
2329
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
2330
|
+
if (!sessionId) {
|
|
2331
|
+
throw new Error(
|
|
2332
|
+
"not inside a Claude Code session (CLAUDE_CODE_SESSION_ID is not set); run /raydr in Claude Code"
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
if (!validSessionId(sessionId)) {
|
|
2336
|
+
throw new Error("CLAUDE_CODE_SESSION_ID is not a Claude Code session id");
|
|
2337
|
+
}
|
|
2338
|
+
const pane = env.HERDR_PANE_ID && !env.HERDR_PANE_ID.startsWith("$") ? env.HERDR_PANE_ID : void 0;
|
|
2339
|
+
const label = basename2(cwd) || cwd;
|
|
2340
|
+
const wasOn = isEnabled(cfg.stateDir, sessionId);
|
|
2341
|
+
const want = verb === "toggle" ? wasOn ? "off" : "on" : verb;
|
|
2342
|
+
if (want === "on") {
|
|
2343
|
+
enableSession(cfg.stateDir, {
|
|
2344
|
+
sessionId,
|
|
2345
|
+
cwd,
|
|
2346
|
+
label,
|
|
2347
|
+
...pane ? { paneId: pane } : {},
|
|
2348
|
+
enabledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2349
|
+
});
|
|
2350
|
+
} else if (want === "off") {
|
|
2351
|
+
disableSession(cfg.stateDir, sessionId);
|
|
2352
|
+
}
|
|
2353
|
+
const announced = want === "status" ? null : await announce(cfg, {
|
|
2354
|
+
sessionId,
|
|
2355
|
+
cwd,
|
|
2356
|
+
label,
|
|
2357
|
+
...pane ? { paneId: pane } : {},
|
|
2358
|
+
state: want === "on" ? "joined" : "left"
|
|
2359
|
+
});
|
|
2360
|
+
const on = want === "status" ? wasOn : want === "on";
|
|
2361
|
+
const where = `${label}${pane ? `, pane ${pane}` : ""}`;
|
|
2362
|
+
const server = announced === true ? `server running on http://${cfg.host}:${cfg.port}` : await health(cfg) ? `server running on http://${cfg.host}:${cfg.port}` : "server not answering";
|
|
2363
|
+
const mode = cfg.sessionsMode === "all" ? " \xB7 RAYDR_SESSIONS=all, every session is heard" : "";
|
|
2364
|
+
return `raydr: ${on ? `on for this session (${where})` : "off for this session"} \xB7 ${server}${mode}`;
|
|
2365
|
+
}
|
|
2366
|
+
async function announce(cfg, body) {
|
|
2367
|
+
if (!cfg.accessKey) return false;
|
|
2368
|
+
try {
|
|
2369
|
+
const res = await fetch(`http://${cfg.host}:${cfg.port}/sessions/announce`, {
|
|
2370
|
+
method: "POST",
|
|
2371
|
+
headers: { authorization: `Bearer ${cfg.accessKey}`, "content-type": "application/json" },
|
|
2372
|
+
body: JSON.stringify(body),
|
|
2373
|
+
signal: AbortSignal.timeout(2e3)
|
|
2374
|
+
});
|
|
2375
|
+
return res.ok;
|
|
2376
|
+
} catch {
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// src/main.ts
|
|
2382
|
+
var USAGE = `raydr \u2014 hear your Claude Code sessions
|
|
2383
|
+
|
|
2384
|
+
Set up:
|
|
2385
|
+
raydr init [--speech P] [--transcription P] [--voice-id V] [--port P] [--force] [--rotate-key]
|
|
2386
|
+
raydr init --migrate-env Rename RAYDAR_* lines in the env file to RAYDR_* (one-shot)
|
|
2387
|
+
Create ~/.config/raydr/env: pick providers, then the keys
|
|
2388
|
+
they need (--elevenlabs-key K / --openai-key K only for
|
|
2389
|
+
scripts; they land in shell history)
|
|
2390
|
+
raydr local setup Install the Python sidecar for kokoro / whisper (idempotent)
|
|
2391
|
+
raydr local serve Run the sidecar in the foreground
|
|
2392
|
+
raydr hooks install [options] Add raydr's hooks and the /raydr command to Claude Code
|
|
2393
|
+
raydr doctor [--offline] Check node, herdr, env file, hooks, server, ElevenLabs
|
|
2394
|
+
|
|
2395
|
+
Run:
|
|
2396
|
+
raydr start | stop | status Run the server in the background (per login session)
|
|
2397
|
+
raydr logs [-f] Show the background server's log
|
|
2398
|
+
raydr serve Run the server in the foreground
|
|
2399
|
+
(start and serve take --no-speech to run without a configured provider)
|
|
2400
|
+
|
|
2401
|
+
Claude Code:
|
|
2402
|
+
raydr session on|off|toggle|status
|
|
2403
|
+
Opt this Claude Code session in or out (what /raydr runs)
|
|
2404
|
+
raydr command Print the /raydr command file (also: --command, --skill)
|
|
2405
|
+
raydr command --install [--project]
|
|
2406
|
+
Write it to ~/.claude/commands/raydr.md (or ./.claude/commands)
|
|
2407
|
+
raydr hooks uninstall [options] Remove the hooks and the command again
|
|
2408
|
+
raydr hooks status Show which settings files carry raydr hooks
|
|
2409
|
+
raydr hook <event> Used by the installed hooks; forwards stdin to the server
|
|
2410
|
+
|
|
2411
|
+
raydr --version | --help Print the version, or this text
|
|
2412
|
+
|
|
2413
|
+
Options:
|
|
2414
|
+
--settings <path> Settings file (default ~/.claude/settings.json)
|
|
2415
|
+
--project Use ./.claude/settings.local.json in the current directory instead
|
|
2416
|
+
--url <url> Server URL to bake into the hooks (default: from the raydr env file)
|
|
2417
|
+
`;
|
|
2418
|
+
var VALUE_FLAGS = {
|
|
2419
|
+
"--settings": "settings",
|
|
2420
|
+
"--url": "url",
|
|
2421
|
+
"--elevenlabs-key": "elevenLabsKey",
|
|
2422
|
+
"--openai-key": "openAiKey",
|
|
2423
|
+
"--speech": "speech",
|
|
2424
|
+
"--transcription": "transcription",
|
|
2425
|
+
"--voice-id": "voiceId",
|
|
2426
|
+
"--port": "port"
|
|
2427
|
+
};
|
|
2428
|
+
var BOOL_FLAGS = {
|
|
2429
|
+
"--project": "project",
|
|
2430
|
+
"--force": "force",
|
|
2431
|
+
"--install": "install",
|
|
2432
|
+
"--migrate-env": "migrateEnv",
|
|
2433
|
+
"--no-speech": "noSpeech",
|
|
2434
|
+
"--rotate-key": "rotateKey",
|
|
2435
|
+
"--offline": "offline",
|
|
2436
|
+
"-f": "follow",
|
|
2437
|
+
"--follow": "follow"
|
|
2438
|
+
};
|
|
2439
|
+
var USER_SETTINGS = join10(homedir3(), ".claude", "settings.json");
|
|
2440
|
+
var PROJECT_SETTINGS = resolve3(".claude", "settings.local.json");
|
|
2441
|
+
function parseArgs(argv) {
|
|
2442
|
+
const args = {
|
|
2443
|
+
positional: [],
|
|
2444
|
+
settings: USER_SETTINGS,
|
|
2445
|
+
project: false,
|
|
2446
|
+
url: void 0,
|
|
2447
|
+
elevenLabsKey: void 0,
|
|
2448
|
+
openAiKey: void 0,
|
|
2449
|
+
speech: void 0,
|
|
2450
|
+
transcription: void 0,
|
|
2451
|
+
voiceId: void 0,
|
|
2452
|
+
port: void 0,
|
|
2453
|
+
force: false,
|
|
2454
|
+
install: false,
|
|
2455
|
+
migrateEnv: false,
|
|
2456
|
+
noSpeech: false,
|
|
2457
|
+
rotateKey: false,
|
|
2458
|
+
offline: false,
|
|
2459
|
+
follow: false
|
|
2460
|
+
};
|
|
2461
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2462
|
+
const a = argv[i] ?? "";
|
|
2463
|
+
const valueFlag = VALUE_FLAGS[a];
|
|
2464
|
+
const boolFlag = BOOL_FLAGS[a];
|
|
2465
|
+
if (valueFlag) {
|
|
2466
|
+
const v = argv[++i];
|
|
2467
|
+
if (!v) fail(`${a} needs a value`);
|
|
2468
|
+
args[valueFlag] = v;
|
|
2469
|
+
} else if (boolFlag) {
|
|
2470
|
+
args[boolFlag] = true;
|
|
2471
|
+
} else if (a.startsWith("-")) {
|
|
2472
|
+
fail(`unknown option ${a}`);
|
|
2473
|
+
} else {
|
|
2474
|
+
args.positional.push(a);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
if (args.project) args.settings = PROJECT_SETTINGS;
|
|
2478
|
+
return args;
|
|
2479
|
+
}
|
|
2480
|
+
function fail(msg) {
|
|
2481
|
+
console.error(`raydr: ${msg}
|
|
2482
|
+
|
|
2483
|
+
${USAGE}`);
|
|
2484
|
+
process.exit(2);
|
|
2485
|
+
}
|
|
2486
|
+
function readSettings(path) {
|
|
2487
|
+
if (!existsSync10(path)) return {};
|
|
2488
|
+
const parsed = JSON.parse(readFileSync8(path, "utf8"));
|
|
2489
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2490
|
+
fail(`${path} is not a JSON object`);
|
|
2491
|
+
}
|
|
2492
|
+
return parsed;
|
|
2493
|
+
}
|
|
2494
|
+
function writeSettings(path, settings) {
|
|
2495
|
+
mkdirSync7(dirname6(path), { recursive: true });
|
|
2496
|
+
if (existsSync10(path)) {
|
|
2497
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2498
|
+
const backup = `${path}.raydr-backup-${stamp}`;
|
|
2499
|
+
copyFileSync(path, backup);
|
|
2500
|
+
console.log(`backup written to ${backup}`);
|
|
2501
|
+
}
|
|
2502
|
+
const tmp = `${path}.raydr-tmp-${process.pid}`;
|
|
2503
|
+
writeFileSync6(tmp, `${JSON.stringify(settings, null, 2)}
|
|
2504
|
+
`);
|
|
2505
|
+
renameSync3(tmp, path);
|
|
2506
|
+
}
|
|
2507
|
+
function raydrOnPath() {
|
|
2508
|
+
const result = spawnSync2("sh", ["-c", "command -v raydr"], { stdio: "ignore" });
|
|
2509
|
+
return result.status === 0;
|
|
2510
|
+
}
|
|
2511
|
+
function cliEntry() {
|
|
2512
|
+
return join10(dirname6(fileURLToPath2(import.meta.url)), "index.js");
|
|
2513
|
+
}
|
|
2514
|
+
async function main(argv) {
|
|
2515
|
+
if (argv.length === 0 || ["--help", "-h", "help"].includes(argv[0] ?? "")) {
|
|
2516
|
+
console.log(USAGE);
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
const normalised = argv.map((a) => a === "--command" || a === "--skill" ? "command" : a);
|
|
2520
|
+
const args = parseArgs(normalised);
|
|
2521
|
+
const [cmd, sub] = args.positional;
|
|
2522
|
+
if (cmd === "serve") {
|
|
2523
|
+
requireProviders(loadConfig(), args.noSpeech);
|
|
2524
|
+
const cfg = loadConfig(void 0, { generateKey: true });
|
|
2525
|
+
const sidecar = usesLocal(cfg) && !args.noSpeech && localReady(cfg) && !await sidecarHealth(cfg) ? await (async () => {
|
|
2526
|
+
await requireLocalPortFree(cfg);
|
|
2527
|
+
return spawnSidecarInline(cfg, cliEntry());
|
|
2528
|
+
})() : null;
|
|
2529
|
+
let running;
|
|
2530
|
+
try {
|
|
2531
|
+
running = await startServer(cfg);
|
|
2532
|
+
} catch (err) {
|
|
2533
|
+
sidecar?.kill("SIGTERM");
|
|
2534
|
+
throw err;
|
|
2535
|
+
}
|
|
2536
|
+
const shutdown = () => {
|
|
2537
|
+
sidecar?.kill("SIGTERM");
|
|
2538
|
+
void running.close().then(() => process.exit(0));
|
|
2539
|
+
};
|
|
2540
|
+
process.on("SIGINT", shutdown);
|
|
2541
|
+
process.on("SIGTERM", shutdown);
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (cmd === "local") {
|
|
2545
|
+
const cfg = loadConfig();
|
|
2546
|
+
if (sub === "setup") {
|
|
2547
|
+
await localSetup(cfg, cliEntry());
|
|
2548
|
+
console.log(
|
|
2549
|
+
"local models ready; select them with RAYDR_SPEECH_PROVIDER=kokoro and RAYDR_TRANSCRIPTION_PROVIDER=whisper (raydr init does)"
|
|
2550
|
+
);
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
if (sub === "serve") {
|
|
2554
|
+
const child = spawnSidecarInline(cfg, cliEntry());
|
|
2555
|
+
await new Promise((r) => child.on("exit", () => r()));
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
fail("local needs one of: setup, serve");
|
|
2559
|
+
}
|
|
2560
|
+
if (cmd === "init" && args.migrateEnv) {
|
|
2561
|
+
const file = defaultEnvFile();
|
|
2562
|
+
const n = migrateEnvFile(file);
|
|
2563
|
+
console.log(
|
|
2564
|
+
n ? `renamed ${n} RAYDAR_ line(s) to RAYDR_ in ${file}` : `no RAYDAR_ lines in ${file}`
|
|
2565
|
+
);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (cmd === "init") {
|
|
2569
|
+
const result = await init({
|
|
2570
|
+
...args.speech !== void 0 ? { speech: args.speech } : {},
|
|
2571
|
+
...args.transcription !== void 0 ? { transcription: args.transcription } : {},
|
|
2572
|
+
...args.elevenLabsKey !== void 0 ? { elevenLabsKey: args.elevenLabsKey } : {},
|
|
2573
|
+
...args.openAiKey !== void 0 ? { openAiKey: args.openAiKey } : {},
|
|
2574
|
+
...args.voiceId !== void 0 ? { voiceId: args.voiceId } : {},
|
|
2575
|
+
...args.port !== void 0 ? { port: args.port } : {},
|
|
2576
|
+
force: args.force,
|
|
2577
|
+
rotateKey: args.rotateKey,
|
|
2578
|
+
runLocalSetup: async () => {
|
|
2579
|
+
if (process.stdin.isTTY !== true) return false;
|
|
2580
|
+
if (!await confirm("Set up the local models now (Python venv, about 2 GB)?"))
|
|
2581
|
+
return false;
|
|
2582
|
+
await localSetup(loadConfig(), cliEntry());
|
|
2583
|
+
return true;
|
|
2584
|
+
}
|
|
2585
|
+
});
|
|
2586
|
+
console.log(result.message);
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
if (cmd === "start") {
|
|
2590
|
+
requireProviders(loadConfig(), args.noSpeech);
|
|
2591
|
+
const cfg = loadConfig(void 0, { generateKey: true });
|
|
2592
|
+
const sidecar = args.noSpeech ? null : await startSidecar(cfg, cliEntry());
|
|
2593
|
+
if (sidecar) console.log(sidecar);
|
|
2594
|
+
console.log(await start(cfg, cliEntry(), { serveArgs: args.noSpeech ? ["--no-speech"] : [] }));
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (cmd === "stop") {
|
|
2598
|
+
const cfg = loadConfig();
|
|
2599
|
+
let failure = null;
|
|
2600
|
+
try {
|
|
2601
|
+
console.log(await stop(cfg, cliEntry()));
|
|
2602
|
+
} catch (err) {
|
|
2603
|
+
failure = err;
|
|
2604
|
+
}
|
|
2605
|
+
const sidecar = await stopSidecar(cfg);
|
|
2606
|
+
if (sidecar) console.log(sidecar);
|
|
2607
|
+
if (failure) throw failure;
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
if (cmd === "status") {
|
|
2611
|
+
const cfg = loadConfig();
|
|
2612
|
+
const pid = readPid(cfg);
|
|
2613
|
+
const h = await health(cfg);
|
|
2614
|
+
const lines = [
|
|
2615
|
+
`raydr: v${VERSION}`,
|
|
2616
|
+
`server: ${h ? `running on http://${cfg.host}:${cfg.port}, pid ${h.pid ?? "?"}, up ${formatUptime(h.uptimeSeconds ?? 0)}, speech ${h.speech ?? "off"}, ${h.clients ?? 0} client(s)` : `not answering on http://${cfg.host}:${cfg.port}`}`,
|
|
2617
|
+
`pid file: ${pid === null ? "none" : `${pid} (${pidLabel(await isRaydrPid(pid, cfg, cliEntry()))})`}`,
|
|
2618
|
+
`log: ${logFile(cfg)}`
|
|
2619
|
+
];
|
|
2620
|
+
if (usesLocal(cfg)) {
|
|
2621
|
+
const lp = readLocalPid(cfg);
|
|
2622
|
+
const lh = await sidecarHealth(cfg);
|
|
2623
|
+
lines.push(
|
|
2624
|
+
`sidecar: ${lh ? `running on http://127.0.0.1:${cfg.localPort}, models ${lh.tts && lh.stt ? "loaded" : "loading"}` : "not answering"}${lp !== null ? ` (pid ${lp}, ${isSidecarPid(lp) === "raydr" ? "alive" : "stale"})` : ""}; log ${localLogFile(cfg)}`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
for (const file of [USER_SETTINGS, PROJECT_SETTINGS]) {
|
|
2628
|
+
if (!existsSync10(file)) continue;
|
|
2629
|
+
const events = installedEvents(readSettings(file));
|
|
2630
|
+
lines.push(`hooks: ${file}: ${events.length ? events.join(", ") : "none"}`);
|
|
2631
|
+
}
|
|
2632
|
+
console.log(lines.join("\n"));
|
|
2633
|
+
initHint();
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
if (cmd === "logs") {
|
|
2637
|
+
const cfg = loadConfig();
|
|
2638
|
+
if (!args.follow) {
|
|
2639
|
+
console.log(tailLog(cfg));
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const tail = spawn3("tail", ["-n", "50", "-f", logFile(cfg)], { stdio: "inherit" });
|
|
2643
|
+
await new Promise((r) => tail.on("exit", () => r()));
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (cmd === "doctor") {
|
|
2647
|
+
const checks = await doctor(loadConfig(), cliEntry(), args.offline);
|
|
2648
|
+
console.log(formatChecks(checks));
|
|
2649
|
+
console.log("add the /raydr command: raydr --command > ~/.claude/commands/raydr.md");
|
|
2650
|
+
initHint();
|
|
2651
|
+
if (checks.some((c) => c.level === "fail")) process.exitCode = 1;
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
if (cmd === "command") {
|
|
2655
|
+
if (sub === "--install" || args.install) {
|
|
2656
|
+
const target = installCommand(commandFileFor(args.settings));
|
|
2657
|
+
console.log(`installed /raydr command at ${target}`);
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
process.stdout.write(commandText());
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (cmd === "session") {
|
|
2664
|
+
if (sub !== "on" && sub !== "off" && sub !== "toggle" && sub !== "status") {
|
|
2665
|
+
fail("session needs one of: on, off, toggle, status");
|
|
2666
|
+
}
|
|
2667
|
+
console.log(await session(sub, loadConfig(), process.env));
|
|
2668
|
+
initHint();
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
if (cmd === "hooks") {
|
|
2672
|
+
if (sub === "install") {
|
|
2673
|
+
const before = readSettings(args.settings);
|
|
2674
|
+
writeSettings(args.settings, installHooks(before, cliEntry(), args.url));
|
|
2675
|
+
console.log(`installed hooks in ${args.settings}`);
|
|
2676
|
+
const command = installCommand(commandFileFor(args.settings));
|
|
2677
|
+
console.log(`installed /raydr command at ${command}`);
|
|
2678
|
+
if (!raydrOnPath()) {
|
|
2679
|
+
console.log(
|
|
2680
|
+
"warning: `raydr` is not on PATH, so /raydr will not work until it is: npm i -g @tensorkit/raydr, or from a checkout cd packages/cli && npm link"
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
console.log(`they will post to ${args.url ?? serverUrlFromConfig()}`);
|
|
2684
|
+
initHint();
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (sub === "uninstall") {
|
|
2688
|
+
const before = readSettings(args.settings);
|
|
2689
|
+
const after = uninstallHooks(before);
|
|
2690
|
+
if (JSON.stringify(after) === JSON.stringify(before)) {
|
|
2691
|
+
console.log(`no raydr hooks in ${args.settings}`);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
writeSettings(args.settings, after);
|
|
2695
|
+
console.log(`removed hooks from ${args.settings}`);
|
|
2696
|
+
const command = commandFileFor(args.settings);
|
|
2697
|
+
if (removeCommand(command)) console.log(`removed ${command}`);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
if (sub === "status") {
|
|
2701
|
+
const files = [.../* @__PURE__ */ new Set([args.settings, USER_SETTINGS, PROJECT_SETTINGS])];
|
|
2702
|
+
for (const file of files) {
|
|
2703
|
+
if (!existsSync10(file)) continue;
|
|
2704
|
+
const events = installedEvents(readSettings(file));
|
|
2705
|
+
console.log(`${file}: ${events.length ? events.join(", ") : "no raydr hooks"}`);
|
|
2706
|
+
const command = commandFileFor(file);
|
|
2707
|
+
console.log(
|
|
2708
|
+
`${command}: ${isRaydrCommandFile(command) ? "/raydr command installed" : "no /raydr command"}`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
console.log(`server: ${serverUrlFromConfig()}`);
|
|
2712
|
+
initHint();
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
fail(cmd ? `unknown command ${[cmd, sub].filter(Boolean).join(" ")}` : "no command given");
|
|
2717
|
+
}
|
|
2718
|
+
function requireProviders(cfg, noSpeech) {
|
|
2719
|
+
if (noSpeech) return;
|
|
2720
|
+
for (const [kind, r] of [
|
|
2721
|
+
["speech", speechReadiness(cfg)],
|
|
2722
|
+
["transcription", transcriptionReadiness(cfg)]
|
|
2723
|
+
]) {
|
|
2724
|
+
if (r.ready) continue;
|
|
2725
|
+
console.error(
|
|
2726
|
+
`${kind} provider ${r.provider} is not configured: missing ${r.missing}; run ${r.fix}`
|
|
2727
|
+
);
|
|
2728
|
+
process.exit(1);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
function initHint() {
|
|
2732
|
+
if (!existsSync10(defaultEnvFile())) {
|
|
2733
|
+
console.log(`run raydr init to set up ${defaultEnvFile().replace(homedir3(), "~")}`);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
function pidLabel(verdict) {
|
|
2737
|
+
return verdict === "raydr" ? "alive" : verdict === "stale" ? "stale" : "unverified, no /proc";
|
|
2738
|
+
}
|
|
2739
|
+
function serverUrlFromConfig() {
|
|
2740
|
+
const cfg = loadConfig();
|
|
2741
|
+
return `http://${cfg.host}:${cfg.port}`;
|
|
2742
|
+
}
|
|
2743
|
+
export {
|
|
2744
|
+
main
|
|
2745
|
+
};
|